diff --git a/.flake8 b/.flake8 new file mode 100644 index 000000000..f82dc0e7e --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +max-line-length = 6000 +exclude = + .git, + __pycache__, + build, + dist, + data/version.py \ No newline at end of file diff --git a/.github/workflows/.flake8 b/.github/workflows/.flake8 new file mode 100644 index 000000000..229297b69 --- /dev/null +++ b/.github/workflows/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 6000 diff --git a/.github/workflows/check-mkbrr-updates.yaml b/.github/workflows/check-mkbrr-updates.yaml new file mode 100644 index 000000000..4f7fa6d26 --- /dev/null +++ b/.github/workflows/check-mkbrr-updates.yaml @@ -0,0 +1,56 @@ +name: Check for New mkbrr Release + +on: + schedule: + - cron: "0 12 * * 1" # Runs every Monday at 12:00 UTC + workflow_dispatch: + +jobs: + check-release: + runs-on: ubuntu-latest + + steps: + - name: Get Latest Release Tag + id: get_release + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/autobrr/mkbrr/releases/latest | jq -r .tag_name) + echo "Latest mkbrr release: $LATEST_TAG" + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + + - name: Check if Issue Already Exists for This Version + id: check_issue + run: | + # Get all issues with the mkbrr label + ISSUES=$(curl -s -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + "https://api.github.com/repos/${{ github.repository }}/issues?state=all&labels=mkbrr&per_page=100") + + # Check if any issue already mentions this specific release tag + ISSUE_EXISTS=$(echo "$ISSUES" | jq -r --arg tag "$LATEST_TAG" \ + '[.[] | select(.body | contains($tag))] | length') + + if [[ "$ISSUE_EXISTS" -gt 0 ]]; then + echo "Issue already exists for mkbrr release $LATEST_TAG" + echo "ISSUE_EXISTS=true" >> $GITHUB_ENV + else + echo "No issue exists for mkbrr release $LATEST_TAG" + echo "ISSUE_EXISTS=false" >> $GITHUB_ENV + echo "NEW_RELEASE=true" >> $GITHUB_ENV + fi + + - name: Create GitHub Issue Notification + if: env.NEW_RELEASE == 'true' && env.ISSUE_EXISTS == 'false' + run: | + # Create issue body with proper variable expansion + ISSUE_BODY="A new mkbrr release ($LATEST_TAG) has been detected. Consider running the update workflow to incorporate the latest mkbrr binaries into Upload-Assistant." + + # Use the variable in the JSON payload + curl -X POST -H "Authorization: Bearer ${{ secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github.v3+json" \ + -d "{ + \"title\": \"New mkbrr Release Available!\", + \"body\": \"$ISSUE_BODY\", + \"labels\": [\"mkbrr\", \"update\"] + }" \ + "https://api.github.com/repos/${{ github.repository }}/issues" + + echo "Created new issue for mkbrr release $LATEST_TAG" \ No newline at end of file diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index 8a5d04241..2778e77c4 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -1,8 +1,10 @@ -name: Create and publish a Docker image +name: Create and publish Docker images on: - push: - branches: ['master'] + release: + types: + - published + workflow_dispatch: env: REGISTRY: ghcr.io @@ -17,41 +19,65 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Log in to the Container registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Extract metadata (tags, labels) for Docker - id: meta - uses: docker/metadata-action@v4 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - name: Get lowercase repo name - id: get_lowercase_repo_name run: | REPO_NAME=${{ env.IMAGE_NAME }} echo "LOWER_CASE_REPO_NAME=${REPO_NAME,,}" >> $GITHUB_ENV - - - name: Get short commit id - id: get_short_commit_id + + - name: Get version for tagging run: | - echo "SHA_SHORT=$(git rev-parse --short HEAD)" >> $GITHUB_ENV - + if [ "${{ github.event_name }}" == "release" ]; then + RELEASE_VERSION="${{ github.event.release.tag_name }}" + echo "VERSION=${RELEASE_VERSION}" >> $GITHUB_ENV + elif [ "${{ github.event_name }}" == "workflow_dispatch" ]; then + BRANCH_NAME="${{ github.ref_name }}" + echo "VERSION=${BRANCH_NAME}" >> $GITHUB_ENV + fi + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=raw,value=latest,enable=${{ github.event_name == 'release' }} + - name: Build and push Docker image - uses: docker/build-push-action@v3 + uses: docker/build-push-action@v5 with: context: . + platforms: linux/amd64 push: true - tags: ${{ steps.meta.outputs.tags }}, ${{ env.REGISTRY }}/${{ env.LOWER_CASE_REPO_NAME }}:${{ env.SHA_SHORT }} + tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Output build information + run: | + echo "✅ Docker images built and pushed successfully!" + echo "🐋 Images:" + echo " - ${{ env.REGISTRY }}/${{ env.LOWER_CASE_REPO_NAME }}:${{ env.VERSION }}" + echo " - ${{ env.REGISTRY }}/${{ env.LOWER_CASE_REPO_NAME }}:latest" + if [ "${{ github.event_name }}" == "release" ]; then + echo "📝 Triggered by release: ${{ github.event.release.tag_name }}" + else + echo "📝 Triggered by manual workflow dispatch on branch: ${{ github.ref_name }}" + fi diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 000000000..85d13a19a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,33 @@ +name: Lint + +on: + push: + branches: + - develop + - master + pull_request: + branches: + - master + - develop + workflow_dispatch: + +jobs: + lint: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.x' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 + + - name: Run linter + run: flake8 . \ No newline at end of file diff --git a/.github/workflows/mkbrr-update.yaml b/.github/workflows/mkbrr-update.yaml new file mode 100644 index 000000000..33ce494c7 --- /dev/null +++ b/.github/workflows/mkbrr-update.yaml @@ -0,0 +1,99 @@ +name: Update mkbrr Binaries + +on: + workflow_dispatch: # Manual trigger only + +jobs: + update-mkbrr: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Get Latest Release Tag + id: get_release + run: | + LATEST_TAG=$(curl -s https://api.github.com/repos/autobrr/mkbrr/releases/latest | jq -r .tag_name) + echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV + + - name: Clean Existing Binaries + run: | + # Remove existing binary directories to avoid conflicts + rm -rf bin/mkbrr/* + + - name: Download Release Assets + run: | + mkdir -p bin/mkbrr + cd bin/mkbrr + + # Fetch latest release assets + assets=$(curl -s https://api.github.com/repos/autobrr/mkbrr/releases/latest | jq -r '.assets[].browser_download_url') + + for url in $assets; do + filename=$(basename $url) + echo "Downloading $filename..." + curl -L -O "$url" + done + + - name: Extract & Organize Binaries + run: | + cd bin/mkbrr + mkdir -p windows/x86_64 macos/arm64 macos/x86_64 linux/amd64 linux/arm linux/arm64 linux/armv6 freebsd/x86_64 + + # Extract and move binaries to correct folders + for file in *; do + case "$file" in + *windows_x86_64.zip) + echo "Extracting $file to windows/x86_64..." + unzip -o "$file" -d windows/x86_64 ;; + *darwin_arm64.tar.gz) + echo "Extracting $file to macos/arm64..." + tar -xzf "$file" -C macos/arm64 ;; + *darwin_x86_64.tar.gz) + echo "Extracting $file to macos/x86_64..." + tar -xzf "$file" -C macos/x86_64 ;; + *freebsd_x86_64.tar.gz) + echo "Extracting $file to freebsd/x86_64..." + tar -xzf "$file" -C freebsd/x86_64 ;; + *linux_amd64.tar.gz|*linux_x86_64.tar.gz) + echo "Extracting $file to linux/amd64..." + tar -xzf "$file" -C linux/amd64 ;; + *linux_arm64.tar.gz) + echo "Extracting $file to linux/arm64..." + tar -xzf "$file" -C linux/arm64 ;; + *linux_armv6.tar.gz) + echo "Extracting $file to linux/armv6..." + tar -xzf "$file" -C linux/armv6 ;; + *linux_arm.tar.gz) + echo "Extracting $file to linux/arm..." + tar -xzf "$file" -C linux/arm ;; + *.apk|*.deb|*.rpm|*.pkg.tar.zst) + echo "Moving $file to linux/amd64..." + mv "$file" linux/amd64 ;; # Move package files + esac + done + + # Ensure executables have correct permissions + find linux macos freebsd -type f -name "mkbrr" -exec chmod +x {} \; + echo "All done with binary extraction" + + - name: Cleanup Unneeded Files + run: | + cd bin/mkbrr + echo "Deleting unneeded archives and checksum files..." + + # Delete all archives & extracted source files + rm -f *.tar.gz *.zip *.apk *.deb *.rpm *.pkg.tar.zst *.txt + + # Verify cleanup + echo "Remaining files in bin/mkbrr:" + ls -R + + - name: Commit & Push Changes + run: | + git config --global user.name "GitHub Actions" + git config --global user.email "actions@github.com" + git add bin/mkbrr + git commit -m "Updated mkbrr binaries to $LATEST_TAG" || echo "No changes to commit" + git push \ No newline at end of file diff --git a/.github/workflows/push_release.yaml b/.github/workflows/push_release.yaml new file mode 100644 index 000000000..51eb65807 --- /dev/null +++ b/.github/workflows/push_release.yaml @@ -0,0 +1,332 @@ +name: Create Release + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g., v1.2.3)' + required: true + type: string + previous_version: + description: 'Previous version for changelog (leave empty for auto-detect)' + required: false + type: string + +jobs: + create-release: + runs-on: ubuntu-latest + permissions: + contents: write + actions: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Get full history for changelog generation + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Validate version format + run: | + VERSION="${{ github.event.inputs.version }}" + if [[ ! $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "❌ Invalid version format. Use format: v1.2.3" + exit 1 + fi + echo "VERSION=${VERSION}" >> $GITHUB_ENV + echo "VERSION_NUMBER=${VERSION#v}" >> $GITHUB_ENV + + - name: Check if tag already exists + run: | + if git tag -l "${{ env.VERSION }}" | grep -q "${{ env.VERSION }}"; then + echo "❌ Tag ${{ env.VERSION }} already exists!" + exit 1 + fi + + - name: Get previous tag for changelog + run: | + if [ -n "${{ github.event.inputs.previous_version }}" ]; then + PREVIOUS_TAG="${{ github.event.inputs.previous_version }}" + else + PREVIOUS_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + fi + + if [ -z "$PREVIOUS_TAG" ]; then + echo "PREVIOUS_TAG=initial" >> $GITHUB_ENV + else + echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_ENV + fi + echo "📝 Previous tag: ${PREVIOUS_TAG:-'none (first release)'}" + + - name: Fetch changelog from local file + run: | + # Path to the local changelog file + CHANGELOG_FILE="data/Upload-Assistant-release_notes.md" + + echo "🔍 Attempting to read changelog from: $CHANGELOG_FILE" + + # Check if the file exists and read it + if [ -f "$CHANGELOG_FILE" ]; then + # Read the file content + GIST_CONTENT=$(cat "$CHANGELOG_FILE") + + # Check if content is not empty + if [ -n "$GIST_CONTENT" ] && [ ${#GIST_CONTENT} -gt 10 ]; then + echo "✅ Successfully read content from local file" + + # Get the first line and check if it matches the version + FIRST_LINE=$(echo "$GIST_CONTENT" | head -n 1) + echo "🔍 First line of file: '$FIRST_LINE'" + echo "🔍 Expected version: '${{ env.VERSION }}'" + + # Check if first line matches the version (with or without markdown formatting) + if [[ "$FIRST_LINE" == "${{ env.VERSION }}" ]] || [[ "$FIRST_LINE" == "# ${{ env.VERSION }}" ]] || [[ "$FIRST_LINE" == "## ${{ env.VERSION }}" ]]; then + echo "✅ First line matches version, removing it and using rest of content" + # Remove the first line and use the rest + PROCESSED_CONTENT=$(echo "$GIST_CONTENT" | tail -n +2) + else + echo "⚠️ First line doesn't match version '${{ env.VERSION }}', skipping file content" + PROCESSED_CONTENT="" + fi + + # Only set the changelog if we have processed content + if [ -n "$PROCESSED_CONTENT" ] && [ ${#PROCESSED_CONTENT} -gt 5 ]; then + echo "✅ Using local file changelog content" + # Save to environment variable + { + echo "GIST_CHANGELOG<> $GITHUB_ENV + else + echo "⚠️ No valid content after processing, skipping file" + echo "GIST_CHANGELOG=" >> $GITHUB_ENV + fi + else + echo "⚠️ File content appears to be empty or too short, skipping" + echo "GIST_CHANGELOG=" >> $GITHUB_ENV + fi + else + echo "⚠️ Changelog file '$CHANGELOG_FILE' not found, continuing without it" + echo "GIST_CHANGELOG=" >> $GITHUB_ENV + fi + + - name: Generate changelog from merged PRs and commits + run: | + if [ "${{ env.PREVIOUS_TAG }}" = "initial" ]; then + # For first release, get all commits since beginning + COMMIT_RANGE="" + else + # Get commits since previous tag + COMMIT_RANGE="${{ env.PREVIOUS_TAG }}..HEAD" + fi + + # Create changelog + { + echo "CHANGELOG<> $GITHUB_ENV + + - name: Update version.py + run: | + TIMESTAMP=$(date +"%Y-%m-%d") + VERSION_FILE="data/version.py" + + # Create version.py if it doesn't exist + if [ ! -f "$VERSION_FILE" ]; then + echo "__version__ = \"0.0.0\"" > "$VERSION_FILE" + echo "" >> "$VERSION_FILE" + fi + + # Prepend new release info + TEMP_FILE=$(mktemp) + echo "__version__ = \"${{ env.VERSION }}\"" > "$TEMP_FILE" + echo "" >> "$TEMP_FILE" + echo "\"\"\"" >> "$TEMP_FILE" + echo "Release Notes for version ${{ env.VERSION }} ($TIMESTAMP):" >> "$TEMP_FILE" + echo "" >> "$TEMP_FILE" + cat <> "$TEMP_FILE" + ${{ env.CHANGELOG }} + EOF + echo "\"\"\"" >> "$TEMP_FILE" + echo "" >> "$TEMP_FILE" + + # Skip the first line of existing version.py (old __version__) + if [ -f "$VERSION_FILE" ]; then + tail -n +2 "$VERSION_FILE" >> "$TEMP_FILE" + fi + mv "$TEMP_FILE" "$VERSION_FILE" + + - name: Commit version update + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add data/version.py + + # Check if there are changes to commit + if git diff --staged --quiet; then + echo "No changes to commit" + else + git commit -m "${{ env.VERSION }}" + echo "✅ Committed version.py update with message: ${{ env.VERSION }}" + fi + + - name: Create and push tag + run: | + # Create the tag on the commit that includes the version update + git tag -a "${{ env.VERSION }}" -m "${{ env.VERSION }}" + + # Push the commit and tag + git push origin HEAD:${{ github.event.repository.default_branch }} + git push origin "${{ env.VERSION }}" + + echo "✅ Created and pushed tag: ${{ env.VERSION }}" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ env.VERSION }} + name: "${{ env.VERSION }}" + body: ${{ env.CHANGELOG }} + draft: false + prerelease: false + generate_release_notes: false # Use only our custom changelog + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Output release information + run: | + echo "✅ Release ${{ env.VERSION }} created successfully!" + echo "📝 Release URL: https://github.com/${{ github.repository }}/releases/tag/${{ env.VERSION }}" + echo "📄 Version file updated and committed with tag: ${{ env.VERSION }}" + echo "🏷️ Tag includes the version commit" + + - name: Trigger Docker build + uses: actions/github-script@v7 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + github.rest.actions.createWorkflowDispatch({ + owner: context.repo.owner, + repo: context.repo.repo, + workflow_id: 'docker-image.yml', + ref: '${{ env.VERSION }}' + }); + console.log('🐳 Triggered Docker workflow for tag: ${{ env.VERSION }}'); diff --git a/.github/workflows/test-run.yaml b/.github/workflows/test-run.yaml new file mode 100644 index 000000000..e59ac357f --- /dev/null +++ b/.github/workflows/test-run.yaml @@ -0,0 +1,276 @@ +name: Run upload test + +on: + release: + types: + - published + workflow_dispatch: + +jobs: + test-upload: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Install FFmpeg + run: | + sudo apt-get update + sudo apt-get install -y ffmpeg + + - name: Install Oxipng + run: | + # Get the latest release info from the correct repository + RELEASE_INFO=$(curl -s "https://api.github.com/repos/oxipng/oxipng/releases/latest") + + # Extract the download URL using jq (more reliable than grep) + OXIPNG_LATEST_URL=$(echo "$RELEASE_INFO" | jq -r '.assets[] | select(.name | contains("x86_64-unknown-linux-musl.tar.gz")) | .browser_download_url') + + if [ -z "$OXIPNG_LATEST_URL" ] || [ "$OXIPNG_LATEST_URL" = "null" ]; then + echo "Failed to find latest Oxipng release URL for x86_64-unknown-linux-musl" + echo "Available assets:" + echo "$RELEASE_INFO" | jq -r '.assets[].name' + exit 1 + fi + + echo "Downloading Oxipng from $OXIPNG_LATEST_URL" + curl -L -o oxipng.tar.gz "$OXIPNG_LATEST_URL" + tar -xzf oxipng.tar.gz + + # Find the oxipng binary and move it + find . -name "oxipng" -type f -executable | head -1 | xargs -I {} sudo mv {} /usr/local/bin/ + sudo chmod +x /usr/local/bin/oxipng + oxipng --version # Verify installation + + - name: Install dependencies + run: | + if [ -f requirements.txt ]; then pip install -r requirements.txt; fi + + - name: Download test video + run: | + curl -L -o big.buck.bunny.2008.m4v "https://download.blender.org/peach/bigbuckbunny_movies/BigBuckBunny_640x360.m4v" + + - name: Copy config template + run: cp data/templates/config.py data/config.py + + - name: Insert API key into config.py + run: | + echo "Attempting to insert API key." + echo "Length of MY_API_KEY environment variable: ${#MY_API_KEY}" + if [ -z "${MY_API_KEY}" ]; then + echo "Warning: MY_API_KEY environment variable is empty or not set." + else + echo "MY_API_KEY environment variable is set." + fi + sed -i "s|\${API_KEY}|${MY_API_KEY}|g" data/config.py + env: + MY_API_KEY: ${{ secrets.MY_API_KEY }} + + - name: Run upload.py with test file and check for errors + run: | + set -e # Exit immediately if a command exits with a non-zero status. + set -o pipefail # Causes a pipeline to return the exit status of the last command in the pipe that returned a non-zero return value. + + OUTPUT_FILE="upload_output.txt" + + # Run the script, redirecting both stdout and stderr to the console and to a file + python upload.py big.buck.bunny.2008.m4v -ua -ns --debug 2>&1 | tee $OUTPUT_FILE + + # The 'python' command above will cause the script to exit if 'upload.py' itself exits non-zero, + # due to 'set -e' and 'set -o pipefail'. + # Now, we check the content of $OUTPUT_FILE for errors that the script might have printed + # even if it exited with code 0. + + echo "--- Full Upload Script Output (for debugging) ---" + cat $OUTPUT_FILE + echo "--- End of Full Upload Script Output ---" + + # Define error patterns to search for. Add more specific patterns as needed. + # Note: Escape special characters for grep if using complex regex. + ERROR_PATTERNS=( + "Traceback (most recent call last):" + "An unexpected error occurred:" + "Connection refused" + "\[Errno 111\] Connection refused" # Escaped for grep + "Error: Unable to import config." + # Add any other critical error messages your script might print + ) + + ERROR_FOUND=0 + for pattern in "${ERROR_PATTERNS[@]}"; do + if grep -q "$pattern" $OUTPUT_FILE; then + echo "::error::Detected error pattern in script output: $pattern" + ERROR_FOUND=1 + fi + done + + if [ $ERROR_FOUND -eq 1 ]; then + echo "Critical error patterns found in script output. Failing the step." + exit 1 + else + echo "No critical error patterns detected in script output." + fi + + - name: Download test video + run: | + curl -L -o tears.of.steel.2012.mkv "https://media.xiph.org/tearsofsteel/tears_of_steel_1080p.webm" + + - name: Run upload.py with test file and check for errors + run: | + set -e # Exit immediately if a command exits with a non-zero status. + set -o pipefail # Causes a pipeline to return the exit status of the last command in the pipe that returned a non-zero return value. + + OUTPUT_FILE="upload_output.txt" + + # Run the script, redirecting both stdout and stderr to the console and to a file + python upload.py tears.of.steel.2012.mkv -ua -ns -siu --tmdb movie/133701 --imdb tt2285752 --tvdb 37711 --debug 2>&1 | tee $OUTPUT_FILE + + # The 'python' command above will cause the script to exit if 'upload.py' itself exits non-zero, + # due to 'set -e' and 'set -o pipefail'. + # Now, we check the content of $OUTPUT_FILE for errors that the script might have printed + # even if it exited with code 0. + + echo "--- Full Upload Script Output (for debugging) ---" + cat $OUTPUT_FILE + echo "--- End of Full Upload Script Output ---" + + # Define error patterns to search for. Add more specific patterns as needed. + # Note: Escape special characters for grep if using complex regex. + ERROR_PATTERNS=( + "Traceback (most recent call last):" + "An unexpected error occurred:" + "Connection refused" + "\[Errno 111\] Connection refused" # Escaped for grep + "Error: Unable to import config." + # Add any other critical error messages your script might print + ) + + ERROR_FOUND=0 + for pattern in "${ERROR_PATTERNS[@]}"; do + if grep -q "$pattern" $OUTPUT_FILE; then + echo "::error::Detected error pattern in script output: $pattern" + ERROR_FOUND=1 + fi + done + + if [ $ERROR_FOUND -eq 1 ]; then + echo "Critical error patterns found in script output. Failing the step." + exit 1 + else + echo "No critical error patterns detected in script output." + fi + + - name: Update README badge for master branch + if: github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master' + run: | + DATE_STR=$(date -u "+%Y-%m-%d %H:%M UTC") + + # Read the first line of README.md + FIRST_LINE=$(head -1 README.md) + echo "Current line: $FIRST_LINE" + + # Extract existing badges using WORKING regex patterns + DOCKER_BADGE=$(echo "$FIRST_LINE" | grep -o '\[\!\[Create and publish a Docker image\][^]]*\]([^)]*)' || echo "[![Create and publish a Docker image](https://github.com/Audionut/Upload-Assistant/actions/workflows/docker-image.yml/badge.svg?branch=master)](https://github.com/Audionut/Upload-Assistant/actions/workflows/docker-image.yml)") + echo "Docker badge: $DOCKER_BADGE" + + # Extract existing TAG badge (preserve it) - using WORKING pattern + TAG_BADGE=$(echo "$FIRST_LINE" | grep -o '\[\!\[Test run ([^)]*[0-9][0-9]*\.[0-9][^)]*)\][^]]*\]([^)]*)' || echo "") + echo "Tag badge: $TAG_BADGE" + + # Create new MASTER badge + MASTER_BADGE="[![Test run (Master Branch)](https://img.shields.io/github/actions/workflow/status/Audionut/Upload-Assistant/test-run.yaml?branch=master&label=Test%20run%20(Master%20Branch%20${DATE_STR// /%20}))](https://github.com/Audionut/Upload-Assistant/actions/workflows/test-run.yaml?query=branch%3Amaster)" + echo "Master badge: $MASTER_BADGE" + + # Combine badges (keep existing TAG badge if present) + if [ -n "$TAG_BADGE" ]; then + NEW_FIRST_LINE="$DOCKER_BADGE $MASTER_BADGE $TAG_BADGE" + echo "Including TAG badge in output" + else + NEW_FIRST_LINE="$DOCKER_BADGE $MASTER_BADGE" + echo "No TAG badge found to preserve" + fi + + echo "New line: $NEW_FIRST_LINE" + + # Update the first line + echo "$NEW_FIRST_LINE" > README.md.new + # Get rest of file (skip first line) + tail -n +2 README.md >> README.md.new + # Replace the file + mv README.md.new README.md + + # Commit and push + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "Update master branch badge with latest run date [skip ci]" || echo "No changes to commit" + git push + + - name: Update README badge for latest tag + if: github.event_name == 'release' + run: | + TAG_NAME="${{ github.event.release.tag_name }}" + DATE_STR=$(date -u "+%Y-%m-%d %H:%M UTC") + + # Read the first line of README.md + FIRST_LINE=$(head -1 README.md) + echo "Current line: $FIRST_LINE" + + # Extract existing badges using WORKING regex patterns + DOCKER_BADGE=$(echo "$FIRST_LINE" | grep -o '\[\!\[Create and publish a Docker image\][^]]*\]([^)]*)' || echo "[![Create and publish a Docker image](https://github.com/Audionut/Upload-Assistant/actions/workflows/docker-image.yml/badge.svg?branch=master)](https://github.com/Audionut/Upload-Assistant/actions/workflows/docker-image.yml)") + echo "Docker badge: $DOCKER_BADGE" + + # Extract existing MASTER badge (preserve it) - using WORKING pattern + MASTER_BADGE=$(echo "$FIRST_LINE" | grep -o '\[\!\[Test run (Master Branch)\][^]]*\]([^)]*)' || echo "") + echo "Master badge: $MASTER_BADGE" + + # Create new TAG badge + TAG_BADGE="[![Test run (${TAG_NAME})](https://img.shields.io/github/actions/workflow/status/Audionut/Upload-Assistant/test-run.yaml?branch=${TAG_NAME}&label=Test%20run%20(${TAG_NAME}%20${DATE_STR// /%20}))](https://github.com/Audionut/Upload-Assistant/actions/workflows/test-run.yaml?query=branch%3A${TAG_NAME})" + echo "New Tag badge: $TAG_BADGE" + + # Combine badges (keep existing MASTER badge if present) + if [ -n "$MASTER_BADGE" ]; then + NEW_FIRST_LINE="$DOCKER_BADGE $MASTER_BADGE $TAG_BADGE" + echo "Including MASTER badge in output" + else + NEW_FIRST_LINE="$DOCKER_BADGE $TAG_BADGE" + echo "No MASTER badge found to preserve" + fi + + echo "New line: $NEW_FIRST_LINE" + + # Update the first line + echo "$NEW_FIRST_LINE" > README.md.new + # Get rest of file (skip first line) + tail -n +2 README.md >> README.md.new + # Replace the file + mv README.md.new README.md + + # First commit change to the current detached HEAD + git config --global user.name "github-actions[bot]" + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git add README.md + git commit -m "Update badge for tag ${TAG_NAME} [skip ci]" || echo "No changes to commit" + + # Now fetch master branch and apply the same changes there + git fetch origin master:master + git checkout master + + # Apply the same badge update to master branch + echo "$NEW_FIRST_LINE" > README.md.new + tail -n +2 README.md >> README.md.new + mv README.md.new README.md + + # Commit and push to master + git add README.md + git commit -m "Update badge for tag ${TAG_NAME} [skip ci]" || echo "No changes to commit" + git push origin master + + - name: Cleanup config.py + if: always() + run: rm -f data/config.py diff --git a/.gitignore b/.gitignore index 38ae89b4c..7551b25a7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,21 @@ data/config.json data/config.py +data/config* data/tags.json data/cookies/*.txt data/cookies/*.pkl data/cookies/*.pickle +data/banned/*.* +bin/mkbrr/* *.mkv .vscode/ __pycache__/ tmp/* -.wdm/ \ No newline at end of file +.wdm/ +.DS_Store +user-args.json +/.vs +data/nfos/* +data/*.json +.venv/* +venv/* diff --git a/Dockerfile b/Dockerfile index 4c3e3ccba..19c75c35c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,56 @@ -FROM alpine:latest +FROM python:3.12 -# add mono repo and mono -RUN apk add --no-cache mono --repository http://dl-cdn.alpinelinux.org/alpine/edge/testing +# Update the package list and install system dependencies including mono +RUN apt-get update && \ + apt-get install -y --no-install-recommends \ + ffmpeg \ + git \ + g++ \ + cargo \ + mktorrent \ + rustc \ + mono-complete \ + nano && \ + rm -rf /var/lib/apt/lists/* -# install requirements -RUN apk add --no-cache --upgrade ffmpeg mediainfo python3 git py3-pip python3-dev g++ cargo mktorrent rust -RUN pip3 install wheel +# Download and install mediainfo 23.04-1 +RUN wget https://mediaarea.net/download/binary/mediainfo/23.04/mediainfo_23.04-1_amd64.Debian_9.0.deb && \ + wget https://mediaarea.net/download/binary/libmediainfo0/23.04/libmediainfo0v5_23.04-1_amd64.Debian_9.0.deb && \ + wget https://mediaarea.net/download/binary/libzen0/0.4.41/libzen0v5_0.4.41-1_amd64.Debian_9.0.deb && \ + apt-get update && \ + apt-get install -y ./libzen0v5_0.4.41-1_amd64.Debian_9.0.deb ./libmediainfo0v5_23.04-1_amd64.Debian_9.0.deb ./mediainfo_23.04-1_amd64.Debian_9.0.deb && \ + rm mediainfo_23.04-1_amd64.Debian_9.0.deb libmediainfo0v5_23.04-1_amd64.Debian_9.0.deb libzen0v5_0.4.41-1_amd64.Debian_9.0.deb -WORKDIR Upload-Assistant +# Set up a virtual environment to isolate our Python dependencies +RUN python -m venv /venv +ENV PATH="/venv/bin:$PATH" -# install reqs +# Install wheel and other Python dependencies +RUN pip install --upgrade pip wheel + +# Set the working directory in the container +WORKDIR /Upload-Assistant + +# Copy the Python requirements file and install Python dependencies COPY requirements.txt . -RUN pip3 install -r requirements.txt +RUN pip install -r requirements.txt + +# Copy the download script +COPY bin/download_mkbrr_for_docker.py bin/ +RUN chmod +x bin/download_mkbrr_for_docker.py -# copy everything +# Download only the required mkbrr binary +RUN python3 bin/download_mkbrr_for_docker.py + +# Copy the rest of the application COPY . . -ENTRYPOINT ["python3", "/Upload-Assistant/upload.py"] \ No newline at end of file +# Ensure mkbrr is executable +RUN find bin/mkbrr -type f -name "mkbrr" -exec chmod +x {} \; + +# Create tmp directory with appropriate permissions +RUN mkdir -p /Upload-Assistant/tmp && chmod 777 /Upload-Assistant/tmp +ENV TMPDIR=/Upload-Assistant/tmp + +# Set the entry point for the container +ENTRYPOINT ["python", "/Upload-Assistant/upload.py"] \ No newline at end of file diff --git a/README.md b/README.md index 596a3303a..1eadb186d 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,130 @@ -# L4G's Upload Assistant +[![Create and publish a Docker image](https://github.com/Audionut/Upload-Assistant/actions/workflows/docker-image.yml/badge.svg?branch=master)](https://github.com/Audionut/Upload-Assistant/actions/workflows/docker-image.yml) [![Test run (Master Branch)](https://img.shields.io/github/actions/workflow/status/Audionut/Upload-Assistant/test-run.yaml?branch=master&label=Test%20run%20(Master%20Branch%202025-07-04%2006:06%20UTC))](https://github.com/Audionut/Upload-Assistant/actions/workflows/test-run.yaml?query=branch%3Amaster) [![Test run (5.1.5.2)](https://img.shields.io/github/actions/workflow/status/Audionut/Upload-Assistant/test-run.yaml?branch=5.1.5.2&label=Test%20run%20(5.1.5.2%202025-07-19%2014:24%20UTC))](https://github.com/Audionut/Upload-Assistant/actions/workflows/test-run.yaml?query=branch%3A5.1.5.2) + +Discord support https://discord.gg/QHHAZu7e2A + +# Upload Assistant A simple tool to take the work out of uploading. +This project is a fork of the original work of L4G https://github.com/L4GSP1KE/Upload-Assistant +Immense thanks to him for establishing this project. Without his (and supporters) time and effort, this fork would not be a thing. +What started as simply pushing some pull requests to keep the main repo inline, as L4G seemed busy with IRL, has since snowballed into full time development, bugs and all. + +Many other forks exist, most are simply a rebranding of this fork without any credit whatsoever. +Better just to be on this fork and bug me about my bugs, rather than bugging someone who can ctrl+c/ctrl+v, but likely can't fix the bugs. + ## What It Can Do: - Generates and Parses MediaInfo/BDInfo. - - Generates and Uploads screenshots. - - Uses srrdb to fix scene filenames - - Can grab descriptions from PTP (automatically on filename match or arg) / BLU (arg) - - Obtains TMDb/IMDb/MAL identifiers. - - Converts absolute to season episode numbering for Anime + - Generates and Uploads screenshots. HDR tonemapping if config. + - Uses srrdb to fix scene names used at sites. + - Can grab descriptions from PTP/BLU/Aither/LST/OE/BHD (with config option automatically on filename match, or using arg). + - Can strip and use existing screenshots from descriptions to skip screenshot generation and uploading. + - Obtains TMDb/IMDb/MAL/TVDB/TVMAZE identifiers. + - Converts absolute to season episode numbering for Anime. Non-Anime support with TVDB credentials - Generates custom .torrents without useless top level folders/nfos. - - Can re-use existing torrents instead of hashing new - - Generates proper name for your upload using Mediainfo/BDInfo and TMDb/IMDb conforming to site rules - - Checks for existing releases already on site - - Uploads to PTP/BLU/BHD/Aither/THR/STC/R4E(limited)/STT/HP/ACM/LCD/LST/NBL/ANT/FL/HUNO/RF/SN - - Adds to your client with fast resume, seeding instantly (rtorrent/qbittorrent/deluge/watch folder) + - Can re-use existing torrents instead of hashing new. + - Can automagically search qBitTorrent version 5+ clients for matching existing torrent. + - Generates proper name for your upload using Mediainfo/BDInfo and TMDb/IMDb conforming to site rules. + - Checks for existing releases already on site. + - Adds to your client with fast resume, seeding instantly (rtorrent/qbittorrent/deluge/watch folder). - ALL WITH MINIMAL INPUT! - - Currently works with .mkv/.mp4/Blu-ray/DVD/HD-DVDs - + - Currently works with .mkv/.mp4/Blu-ray/DVD/HD-DVDs. +## Supported Sites: -## Coming Soon: - - Features - - - +|Name|Acronym|Name|Acronym| +|-|:-:|-|:-:| +|Aither|AITHER|Alpharatio|AR| +|AmigosShareClub|ASC|AnimeLovers|AL| +|Anthelion|ANT|AsianCinema|ACM| +|AvistaZ|AZ|Beyond-HD|BHD| +|BitHDTV|BHDTV|Blutopia|BLU| +|BrasilJapão-Share|BJS|BrasilTracker|BT| +|CapybaraBR|CBR|Cinematik|TIK| +|DarkPeers|DP|DigitalCore|DC| +|FearNoPeer|FNP|FileList|FL| +|Friki|FRIKI|FunFile|FF| +|GreatPosterWall|GPW|hawke-uno|HUNO| +|HDBits|HDB|HD-Space|HDS| +|HD-Torrents|HDT|HomieHelpDesk|HHD| +|ItaTorrents|ITT|LastDigitalUnderground|LDU| +|Lat-Team|LT|Locadora|LCD| +|LST|LST|MoreThanTV|MTV| +|Nebulance|NBL|OldToonsWorld|OTW| +|OnlyEncodes+|OE|PassThePopcorn|PTP| +|PolishTorrent|PTT|Portugas|PT| +|PTerClub|PTER|PrivateHD|PHD| +|PTSKIT|PTS|Racing4Everyone|R4E| +|Rastastugan|RAS|ReelFLiX|RF| +|RetroFlix|RTF|Samaritano|SAM| +|seedpool|SP|ShareIsland|SHRI| +|SkipTheCommericals|STC|SpeedApp|SPD| +|Swarmazon|SN|TorrentHR|THR| +|TorrentLeech|TL|ToTheGlory|TTG| +|TVChaosUK|TVC|UHDShare|UHD| +|ULCX|ULCX|UTOPIA|UTP| +|YOiNKED|YOINK|YUSCENE|YUS| ## **Setup:** - - **REQUIRES AT LEAST PYTHON 3.7 AND PIP3** + - **REQUIRES AT LEAST PYTHON 3.9 AND PIP3** - Needs [mono](https://www.mono-project.com/) on linux systems for BDInfo - Also needs MediaInfo and ffmpeg installed on your system - On Windows systems, ffmpeg must be added to PATH (https://windowsloop.com/install-ffmpeg-windows-10/) - On linux systems, get it from your favorite package manager - - Clone the repo to your system `git clone https://github.com/L4GSP1KE/Upload-Assistant.git` + - If you have issues with ffmpeg, such as `max workers` errors, see this [wiki](https://github.com/Audionut/Upload-Assistant/wiki/ffmpeg---max-workers-issues) + - Get the source: + - Clone the repo to your system `git clone https://github.com/Audionut/Upload-Assistant.git` + - Fetch all of the release tags `git fetch --all --tags` + - Check out the specifc release: see [releases](https://github.com/Audionut/Upload-Assistant/releases) + - `git checkout tags/tagname` where `tagname` is the release name, eg `v5.0.0` + - or download a zip of the source from the releases page and create/overwrite a local copy. + - Install necessary python modules `pip3 install --user -U -r requirements.txt` + - `sudo apt install pip` if needed + - If you receive an error about externally managed environment, or otherwise wish to keep UA python separate: + - Install virtual python environment `python3 -m venv venv` + - Activate the virtual environment `source venv/bin/activate` + - Then install the requirements `pip install -r requirements.txt` + - From the installation directory, run `python3 config-generator.py` + - OR - Copy and Rename `data/example-config.py` to `data/config.py` - - Edit `config.py` to use your information (more detailed information in the [wiki](https://github.com/L4GSP1KE/Upload-Assistant/wiki)) - - tmdb_api (v3) key can be obtained from https://developers.themoviedb.org/3/getting-started/introduction + - Edit `config.py` to use your information (more detailed information in the [wiki](https://github.com/Audionut/Upload-Assistant/wiki)) + - tmdb_api key can be obtained from https://www.themoviedb.org/settings/api - image host api keys can be obtained from their respective sites - - Install necessary python modules `pip3 install --user -U -r requirements.txt` - - - **Additional Resources are found in the [wiki](https://github.com/L4GSP1KE/Upload-Assistant/wiki)** - + **Additional Resources are found in the [wiki](https://github.com/Audionut/Upload-Assistant/wiki)** + Feel free to contact me if you need help, I'm not that hard to find. ## **Updating:** - To update first navigate into the Upload-Assistant directory: `cd Upload-Assistant` - - Run a `git pull` to grab latest updates + - `git fetch --all --tags` + - `git checkout tags/tagname` + - Or download a fresh zip from the releases page and overwrite existing files - Run `python3 -m pip install --user -U -r requirements.txt` to ensure dependencies are up to date + - Run `python3 config-generator.py` and select to grab new UA config options. + ## **CLI Usage:** - - `python3 upload.py /downloads/path/to/content --args` - - Args are OPTIONAL, for a list of acceptable args, pass `--help` + + `python3 upload.py "/path/to/content" --args` + + Args are OPTIONAL and ALWAYS follow path, for a list of acceptable args, pass `--help`. + Path works best in quotes. + ## **Docker Usage:** - Visit our wonderful [docker usage wiki page](https://github.com/L4GSP1KE/Upload-Assistant/wiki/Docker) + Visit our wonderful [docker usage wiki page](https://github.com/Audionut/Upload-Assistant/wiki/Docker) + + Also see this excellent video put together by a community member https://videos.badkitty.zone/ua + +## **Attributions:** + +Built with updated BDInfoCLI from https://github.com/rokibhasansagar/BDInfoCLI-ng + +

+ mkbrr   + FFmpeg   + Mediainfo   + TMDb   + IMDb   + TheTVDB   + TVmaze +

diff --git a/bin/BDInfo/BDInfo.exe b/bin/BDInfo/BDInfo.exe index e2462867e..82e0a6f86 100644 Binary files a/bin/BDInfo/BDInfo.exe and b/bin/BDInfo/BDInfo.exe differ diff --git a/bin/BDInfo/System.Resources.Extensions.dll b/bin/BDInfo/System.Resources.Extensions.dll new file mode 100644 index 000000000..939c9f582 Binary files /dev/null and b/bin/BDInfo/System.Resources.Extensions.dll differ diff --git a/bin/MI/windows/MediaInfo.exe b/bin/MI/windows/MediaInfo.exe new file mode 100644 index 000000000..78e0e4d8b Binary files /dev/null and b/bin/MI/windows/MediaInfo.exe differ diff --git a/bin/download_mkbrr_for_docker.py b/bin/download_mkbrr_for_docker.py new file mode 100644 index 000000000..149c6d3ae --- /dev/null +++ b/bin/download_mkbrr_for_docker.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import platform +import requests +import tarfile +import os +import sys +from pathlib import Path + + +def download_mkbrr_for_docker(base_dir=".", version="v1.14.0"): + """Download mkbrr binary for Docker - synchronous version""" + + system = platform.system().lower() + machine = platform.machine().lower() + print(f"Detected system: {system}, architecture: {machine}") + + if system != "linux": + raise Exception(f"This script is for Docker/Linux only, detected: {system}") + + platform_map = { + 'x86_64': {'file': 'linux_x86_64.tar.gz', 'folder': 'linux/amd64'}, + 'amd64': {'file': 'linux_x86_64.tar.gz', 'folder': 'linux/amd64'}, + 'arm64': {'file': 'linux_arm64.tar.gz', 'folder': 'linux/arm64'}, + 'aarch64': {'file': 'linux_arm64.tar.gz', 'folder': 'linux/arm64'}, + 'armv7l': {'file': 'linux_arm.tar.gz', 'folder': 'linux/arm'}, + 'arm': {'file': 'linux_arm.tar.gz', 'folder': 'linux/arm'}, + } + + if machine not in platform_map: + raise Exception(f"Unsupported architecture: {machine}") + + platform_info = platform_map[machine] + file_pattern = platform_info['file'] + folder_path = platform_info['folder'] + + print(f"Using file pattern: {file_pattern}") + print(f"Target folder: {folder_path}") + + bin_dir = Path(base_dir) / "bin" / "mkbrr" / folder_path + bin_dir.mkdir(parents=True, exist_ok=True) + binary_path = bin_dir / "mkbrr" + version_path = bin_dir / version + + if version_path.exists(): + print(f"mkbrr {version} already exists, skipping download") + return str(binary_path) + + if binary_path.exists(): + binary_path.unlink() + + # Download URL + download_url = f"https://github.com/autobrr/mkbrr/releases/download/{version}/mkbrr_{version[1:]}_{file_pattern}" + print(f"Downloading from: {download_url}") + + try: + response = requests.get(download_url, stream=True) + response.raise_for_status() + + temp_archive = bin_dir / f"temp_{file_pattern}" + with open(temp_archive, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + + print(f"Downloaded {file_pattern}") + + with tarfile.open(temp_archive, 'r:gz') as tar_ref: + tar_ref.extractall(bin_dir) + + temp_archive.unlink() + + if binary_path.exists(): + os.chmod(binary_path, 0o755) + print(f"mkbrr binary ready at: {binary_path}") + + with open(version_path, 'w') as f: + f.write(f"mkbrr version {version} installed successfully.") + + return str(binary_path) + else: + raise Exception(f"Failed to extract mkbrr binary to {binary_path}") + + except Exception as e: + print(f"Error downloading mkbrr: {e}") + sys.exit(1) + + +if __name__ == "__main__": + download_mkbrr_for_docker() diff --git a/bin/get_mkbrr.py b/bin/get_mkbrr.py new file mode 100644 index 000000000..23c631367 --- /dev/null +++ b/bin/get_mkbrr.py @@ -0,0 +1,122 @@ +import platform +import requests +import tarfile +import zipfile +import stat +import os +from pathlib import Path +from src.console import console + + +async def ensure_mkbrr_binary(base_dir, debug, version=None): + system = platform.system().lower() + machine = platform.machine().lower() + if debug: + console.print(f"[blue]Detected system: {system}, architecture: {machine}[/blue]") + + platform_map = { + 'windows': { + 'x86_64': {'file': 'windows_x86_64.zip', 'folder': 'windows/x86_64'}, + 'amd64': {'file': 'windows_x86_64.zip', 'folder': 'windows/x86_64'}, + }, + 'darwin': { + 'arm64': {'file': 'darwin_arm64.tar.gz', 'folder': 'macos/arm64'}, + 'x86_64': {'file': 'darwin_x86_64.tar.gz', 'folder': 'macos/x86_64'}, + 'amd64': {'file': 'darwin_x86_64.tar.gz', 'folder': 'macos/x86_64'}, + }, + 'linux': { + 'x86_64': {'file': 'linux_x86_64.tar.gz', 'folder': 'linux/amd64'}, + 'amd64': {'file': 'linux_x86_64.tar.gz', 'folder': 'linux/amd64'}, + 'arm64': {'file': 'linux_arm64.tar.gz', 'folder': 'linux/arm64'}, + 'aarch64': {'file': 'linux_arm64.tar.gz', 'folder': 'linux/arm64'}, + 'armv7l': {'file': 'linux_arm.tar.gz', 'folder': 'linux/arm'}, + 'armv6l': {'file': 'linux_arm.tar.gz', 'folder': 'linux/armv6'}, + 'arm': {'file': 'linux_arm.tar.gz', 'folder': 'linux/arm'}, + }, + 'freebsd': { + 'x86_64': {'file': 'freebsd_x86_64.tar.gz', 'folder': 'freebsd/x86_64'}, + 'amd64': {'file': 'freebsd_x86_64.tar.gz', 'folder': 'freebsd/x86_64'}, + } + } + + if system not in platform_map or machine not in platform_map[system]: + raise Exception(f"Unsupported platform: {system} {machine}") + + platform_info = platform_map[system][machine] + file_pattern = platform_info['file'] + folder_path = platform_info['folder'] + if debug: + console.print(f"[blue]Using file pattern: {file_pattern}[/blue]") + console.print(f"[blue]Target folder: {folder_path}[/blue]") + + bin_dir = Path(base_dir) / "bin" / "mkbrr" / folder_path + bin_dir.mkdir(parents=True, exist_ok=True) + if debug: + console.print(f"[blue]Binary directory: {bin_dir}[/blue]") + + binary_name = "mkbrr.exe" if system == "windows" else "mkbrr" + binary_path = bin_dir / binary_name + if debug: + console.print(f"[blue]Binary path: {binary_path}[/blue]") + + wrong_version = False + version_path = bin_dir / version + if version_path.exists() and version_path.is_file(): + if debug: + console.print("[blue]mkbrr version is up to date[/blue]") + return str(binary_path) + else: + wrong_version = True + + if binary_path.exists() and binary_path.is_file(): + if not system == "windows": + os.chmod(binary_path, 0o755) + os.remove(binary_path) + if debug: + console.print(f"[blue]Removed existing binary at: {binary_path}[/blue]") + + if wrong_version and version_path.exists(): + if not system == "windows": + os.chmod(version_path, 0o644) + os.remove(version_path) + if debug: + console.print(f"[blue]Removed existing version file at: {version_path}[/blue]") + + download_url = f"https://github.com/autobrr/mkbrr/releases/download/{version}/mkbrr_{version[1:]}_{file_pattern}" + if debug: + console.print(f"[blue]Download URL: {download_url}[/blue]") + + try: + response = requests.get(download_url, stream=True) + response.raise_for_status() + + temp_archive = bin_dir / f"temp_{file_pattern}" + with open(temp_archive, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + f.write(chunk) + if debug: + console.print(f"[green]Downloaded {file_pattern}[/green]") + + if file_pattern.endswith('.zip'): + with zipfile.ZipFile(temp_archive, 'r') as zip_ref: + zip_ref.extractall(bin_dir) + elif file_pattern.endswith('.tar.gz'): + with tarfile.open(temp_archive, 'r:gz') as tar_ref: + tar_ref.extractall(bin_dir) + + temp_archive.unlink() + + if system != "windows" and binary_path.exists(): + binary_path.chmod(binary_path.stat().st_mode | stat.S_IEXEC) + + if not binary_path.exists(): + raise Exception(f"Failed to extract mkbrr binary to {binary_path}") + + with open(version_path, 'w') as f: + f.write(f"mkbrr version {version} installed successfully.") + return str(binary_path) + + except requests.RequestException as e: + raise Exception(f"Failed to download mkbrr binary: {e}") + except (zipfile.BadZipFile, tarfile.TarError) as e: + raise Exception(f"Failed to extract mkbrr binary: {e}") diff --git a/cogs/commands.py b/cogs/commands.py index 52efe6ac2..c40d66123 100644 --- a/cogs/commands.py +++ b/cogs/commands.py @@ -1,4 +1,3 @@ -from discord.ext.commands.errors import CommandInvokeError from src.prep import Prep from src.args import Args from src.clients import Clients @@ -8,27 +7,25 @@ from src.trackers.AITHER import AITHER from src.trackers.STC import STC from src.trackers.LCD import LCD -from data.config import config +from src.trackers.CBR import CBR +from data.config import config # type: ignore -import discord -from discord.ext import commands +import discord # type: ignore +from discord.ext import commands # type: ignore import os from datetime import datetime import asyncio import json -import shutil import multiprocessing from pathlib import Path from glob import glob import argparse - class Commands(commands.Cog): def __init__(self, bot): self.bot = bot - @commands.Cog.listener() async def on_guild_join(self, guild): """ @@ -45,7 +42,7 @@ async def upload(self, ctx, path, *args, message_id=0, search_args=tuple()): return parser = Args(config) - if path == None: + if path is None: await ctx.send("Missing Path") return elif path.lower() == "-h": @@ -60,17 +57,17 @@ async def upload(self, ctx, path, *args, message_id=0, search_args=tuple()): try: args = (meta['path'],) + args + search_args meta, help, before_args = parser.parse(args, meta) - except SystemExit as error: + except SystemExit: await ctx.send(f"Invalid argument detected, use `{config['DISCORD']['command_prefix']}args` for list of valid args") return - if meta['imghost'] == None: + if meta['imghost'] is None: meta['imghost'] = config['DEFAULT']['img_host_1'] # if not meta['unattended']: # ua = config['DEFAULT'].get('auto_mode', False) # if str(ua).lower() == "true": # meta['unattended'] = True prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=config) - preparing_embed = discord.Embed(title=f"Preparing to upload:", description=f"```{path}```", color=0xffff00) + preparing_embed = discord.Embed(title="Preparing to upload:", description=f"```{path}```", color=0xffff00) if message_id == 0: message = await ctx.send(embed=preparing_embed) meta['embed_msg_id'] = message.id @@ -86,7 +83,6 @@ async def upload(self, ctx, path, *args, message_id=0, search_args=tuple()): else: await ctx.send("Invalid Path") - @commands.command() async def args(self, ctx): f""" @@ -102,57 +98,7 @@ async def args(self, ctx): await ctx.send(f"```{help[1991:]}```") else: await ctx.send(help.format_help()) - # await ctx.send(""" - # ```Optional arguments: - - # -s, --screens [SCREENS] - # Number of screenshots - # -c, --category [{movie,tv,fanres}] - # Category - # -t, --type [{disc,remux,encode,webdl,web-dl,webrip,hdtv}] - # Type - # -res, --resolution - # [{2160p,1080p,1080i,720p,576p,576i,480p,480i,8640p,4320p,other}] - # Resolution - # -tmdb, --tmdb [TMDB] - # TMDb ID - # -g, --tag [TAG] - # Group Tag - # -serv, --service [SERVICE] - # Streaming Service - # -edition, --edition [EDITION] - # Edition - # -d, --desc [DESC] - # Custom Description (string) - # -nfo, --nfo - # Use .nfo in directory for description - # -k, --keywords [KEYWORDS] - # Add comma seperated keywords e.g. 'keyword, keyword2, etc' - # -reg, --region [REGION] - # Region for discs - # -a, --anon Upload anonymously - # -st, --stream Stream Optimized Upload - # -debug, --debug Debug Mode```""") - - - # @commands.group(invoke_without_command=True) - # async def foo(self, ctx): - # """ - # check out my subcommands! - # """ - # await ctx.send('check out my subcommands!') - - # @foo.command(aliases=['an_alias']) - # async def bar(self, ctx): - # """ - # I have an alias!, I also belong to command 'foo' - # """ - # await ctx.send('foo bar!') - - - - - + @commands.command() async def edit(self, ctx, uuid=None, *args): """ @@ -160,7 +106,7 @@ async def edit(self, ctx, uuid=None, *args): """ if ctx.channel.id != int(config['DISCORD']['discord_channel_id']): return - if uuid == None: + if uuid is None: await ctx.send("Missing ID, please try again using the ID in the footer") parser = Args(config) base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) @@ -171,7 +117,7 @@ async def edit(self, ctx, uuid=None, *args): except FileNotFoundError: await ctx.send("ID not found, please try again using the ID in the footer") return - prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=config) + prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=config) try: args = (meta['path'],) + args meta, help, before_args = parser.parse(args, meta) @@ -182,15 +128,10 @@ async def edit(self, ctx, uuid=None, *args): new_msg = await msg.channel.send(f"Editing {meta['uuid']}") meta['embed_msg_id'] = new_msg.id meta['edit'] = True - meta = await prep.gather_prep(meta=meta, mode="discord") + meta = await prep.gather_prep(meta=meta, mode="discord") meta['name_notag'], meta['name'], meta['clean_name'], meta['potential_missing'] = await prep.get_name(meta) await self.send_embed_and_upload(ctx, meta) - - - - - @commands.group(invoke_without_command=True) async def search(self, ctx, *, args=None): """ @@ -205,14 +146,14 @@ async def search(self, ctx, *, args=None): args = args.replace(search_terms, '') while args.startswith(" "): args = args[1:] - except SystemExit as error: + except SystemExit: await ctx.send(f"Invalid argument detected, use `{config['DISCORD']['command_prefix']}args` for list of valid args") return if ctx.channel.id != int(config['DISCORD']['discord_channel_id']): return search = Search(config=config) - if search_terms == None: + if search_terms is None: await ctx.send("Missing search term(s)") return files_total = await search.searchFile(search_terms) @@ -233,14 +174,12 @@ async def search(self, ctx, *, args=None): message = await ctx.send(embed=embed) await message.add_reaction(config['DISCORD']['discord_emojis']['UPLOAD']) channel = message.channel - def check(reaction, user): if reaction.message.id == message.id: - if str(user.id) == config['DISCORD']['admin_id']: + if str(user.id) == config['DISCORD']['admin_id']: if str(reaction.emoji) == config['DISCORD']['discord_emojis']['UPLOAD']: return reaction - try: await self.bot.wait_for("reaction_add", timeout=120, check=check) @@ -249,8 +188,6 @@ def check(reaction, user): else: await self.upload(ctx, files_total[0], search_args=tuple(args.split(" ")), message_id=message.id) - - @search.command() async def dir(self, ctx, *, args=None): """ @@ -265,14 +202,14 @@ async def dir(self, ctx, *, args=None): args = args.replace(search_terms, '') while args.startswith(" "): args = args[1:] - except SystemExit as error: + except SystemExit: await ctx.send(f"Invalid argument detected, use `{config['DISCORD']['command_prefix']}args` for list of valid args") return if ctx.channel.id != int(config['DISCORD']['discord_channel_id']): return search = Search(config=config) - if search_terms == None: + if search_terms is None: await ctx.send("Missing search term(s)") return folders_total = await search.searchFolder(search_terms) @@ -294,13 +231,11 @@ async def dir(self, ctx, *, args=None): await message.add_reaction(config['DISCORD']['discord_emojis']['UPLOAD']) channel = message.channel - def check(reaction, user): if reaction.message.id == message.id: - if str(user.id) == config['DISCORD']['admin_id']: + if str(user.id) == config['DISCORD']['admin_id']: if str(reaction.emoji) == config['DISCORD']['discord_emojis']['UPLOAD']: return reaction - try: await self.bot.wait_for("reaction_add", timeout=120, check=check) @@ -310,39 +245,31 @@ def check(reaction, user): await self.upload(ctx, path=folders_total[0], search_args=tuple(args.split(" ")), message_id=message.id) # await ctx.send(folders_total) return - - - - - - - - - async def send_embed_and_upload(self,ctx,meta): + + async def send_embed_and_upload(self, ctx, meta): prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=config) meta['name_notag'], meta['name'], meta['clean_name'], meta['potential_missing'] = await prep.get_name(meta) - - if meta.get('uploaded_screens', False) == False: + + if meta.get('uploaded_screens', False) is False: if meta.get('embed_msg_id', '0') != '0': message = await ctx.fetch_message(meta['embed_msg_id']) await message.edit(embed=discord.Embed(title="Uploading Screenshots", color=0xffff00)) else: message = await ctx.send(embed=discord.Embed(title="Uploading Screenshots", color=0xffff00)) meta['embed_msg_id'] = message.id - + channel = message.channel.id return_dict = multiprocessing.Manager().dict() - u = multiprocessing.Process(target = prep.upload_screens, args=(meta, meta['screens'], 1, 0, meta['screens'], [], return_dict)) + u = multiprocessing.Process(target=prep.upload_screens, args=(meta, meta['screens'], 1, 0, meta['screens'], [], return_dict)) u.start() - while u.is_alive() == True: + while u.is_alive() is True: await asyncio.sleep(3) meta['image_list'] = return_dict['image_list'] if meta['debug']: print(meta['image_list']) meta['uploaded_screens'] = True - #Create base .torrent - + # Create base .torrent if len(glob(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent")) == 0: if meta.get('embed_msg_id', '0') != '0': message = await ctx.fetch_message(int(meta['embed_msg_id'])) @@ -351,15 +278,15 @@ async def send_embed_and_upload(self,ctx,meta): message = await ctx.send(embed=discord.Embed(title="Creating .torrent", color=0xffff00)) meta['embed_msg_id'] = message.id channel = message.channel - if meta['nohash'] == False: - if meta.get('torrenthash', None) != None: - reuse_torrent = await client.find_existing_torrent(meta) - if reuse_torrent != None: + if meta['nohash'] is False: + if meta.get('torrenthash', None) is not None: + reuse_torrent = await client.find_existing_torrent(meta) # noqa F821 + if reuse_torrent is not None: prep.create_base_from_existing_torrent(reuse_torrent, meta['base_dir'], meta['uuid']) - p = multiprocessing.Process(target = prep.create_torrent, args=(meta, Path(meta['path']))) + p = multiprocessing.Process(target=prep.create_torrent, args=(meta, Path(meta['path']))) p.start() - while p.is_alive() == True: + while p.is_alive() is True: await asyncio.sleep(5) if int(meta.get('randomized', 0)) >= 1: @@ -367,8 +294,7 @@ async def send_embed_and_upload(self,ctx,meta): else: meta['client'] = 'none' - - #Format for embed + # Format for embed if meta['tag'] == "": tag = "" else: @@ -387,19 +313,25 @@ async def send_embed_and_upload(self,ctx,meta): res = meta['resolution'] missing = await self.get_missing(meta) - embed=discord.Embed(title=f"Upload: {meta['title']}", url=f"https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}", description=meta['overview'], color=0x0080ff, timestamp=datetime.utcnow()) + embed = discord.Embed( + title=f"Upload: {meta['title']}", + url=f"https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}", + description=meta['overview'], + color=0x0080ff, + timestamp=datetime.utcnow() + ) embed.add_field(name="Links", value=f"[TMDB](https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}){imdb}{tvdb}") embed.add_field(name=f"{res} / {meta['type']}{tag}", value=f"```{meta['name']}```", inline=False) if missing != []: - embed.add_field(name=f"POTENTIALLY MISSING INFORMATION:", value="\n".join(missing), inline=False) + embed.add_field(name="POTENTIALLY MISSING INFORMATION:", value="\n".join(missing), inline=False) embed.set_thumbnail(url=f"https://image.tmdb.org/t/p/original{meta['poster']}") embed.set_footer(text=meta['uuid']) - embed.set_author(name="L4G's Upload Assistant", url="https://github.com/L4GSP1KE/Upload-Assistant", icon_url="https://images2.imgbox.com/6e/da/dXfdgNYs_o.png") - + embed.set_author(name="L4G's Upload Assistant", url="https://github.com/Audionut/Upload-Assistant", icon_url="https://images2.imgbox.com/6e/da/dXfdgNYs_o.png") + message = await ctx.fetch_message(meta['embed_msg_id']) await message.edit(embed=embed) - if meta.get('trackers', None) != None: + if meta.get('trackers', None) is not None: trackers = meta['trackers'] else: trackers = config['TRACKERS']['default_trackers'] @@ -419,21 +351,24 @@ async def send_embed_and_upload(self,ctx,meta): await asyncio.sleep(0.3) if "LCD" in each.replace(' ', ''): await message.add_reaction(config['DISCORD']['discord_emojis']['LCD']) - await asyncio.sleep(0.3) + await asyncio.sleep(0.3) + if "CBR" in each.replace(' ', ''): + await message.add_reaction(config['DISCORD']['discord_emojis']['CBR']) + await asyncio.sleep(0.3) await message.add_reaction(config['DISCORD']['discord_emojis']['MANUAL']) await asyncio.sleep(0.3) await message.add_reaction(config['DISCORD']['discord_emojis']['CANCEL']) await asyncio.sleep(0.3) await message.add_reaction(config['DISCORD']['discord_emojis']['UPLOAD']) - #Save meta to json - with open (f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: + # Save meta to json + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: json.dump(meta, f, indent=4) f.close() - + def check(reaction, user): if reaction.message.id == meta['embed_msg_id']: - if str(user.id) == config['DISCORD']['admin_id']: + if str(user.id) == config['DISCORD']['admin_id']: if str(reaction.emoji) == config['DISCORD']['discord_emojis']['UPLOAD']: return reaction if str(reaction.emoji) == config['DISCORD']['discord_emojis']['CANCEL']: @@ -451,7 +386,7 @@ def check(reaction, user): await msg.clear_reactions() await msg.edit(embed=timeout_embed) return - except: + except Exception: print("timeout after edit") pass except CancelException: @@ -460,20 +395,9 @@ def check(reaction, user): await msg.clear_reactions() await msg.edit(embed=cancel_embed) return - # except ManualException: - # msg = await ctx.fetch_message(meta['embed_msg_id']) - # await msg.clear_reactions() - # archive_url = await prep.package(meta) - # if archive_url == False: - # archive_fail_embed = discord.Embed(title="Unable to upload prep files", description=f"The files can be found at `tmp/{meta['title']}.tar`", color=0xff0000) - # await msg.edit(embed=archive_fail_embed) - # else: - # archive_embed = discord.Embed(title="Files can be found at:",description=f"{archive_url} or `tmp/{meta['title']}.tar`", color=0x00ff40) - # await msg.edit(embed=archive_embed) - # return else: - - #Check which are selected and upload to them + + # Check which are selected and upload to them msg = await ctx.fetch_message(message.id) tracker_list = list() tracker_emojis = config['DISCORD']['discord_emojis'] @@ -484,59 +408,59 @@ def check(reaction, user): tracker = list(config['DISCORD']['discord_emojis'].keys())[list(config['DISCORD']['discord_emojis'].values()).index(str(each))] if tracker not in ("UPLOAD"): tracker_list.append(tracker) - + upload_embed_description = ' / '.join(tracker_list) upload_embed = discord.Embed(title=f"Uploading `{meta['name']}` to:", description=upload_embed_description, color=0x00ff40) await msg.edit(embed=upload_embed) await msg.clear_reactions() - - - client = Clients(config=config) if "MANUAL" in tracker_list: for manual_tracker in tracker_list: manual_tracker = manual_tracker.replace(" ", "") if manual_tracker.upper() == "BLU": - blu = BLU(config=config) + blu = BLU(config=config) await blu.edit_desc(meta) if manual_tracker.upper() == "BHD": bhd = BHD(config=config) - await bhd.edit_desc(meta) + await bhd.edit_desc(meta) if manual_tracker.upper() == "AITHER": aither = AITHER(config=config) - await aither.edit_desc(meta) + await aither.edit_desc(meta) if manual_tracker.upper() == "STC": stc = STC(config=config) - await stc.edit_desc(meta) + await stc.edit_desc(meta) if manual_tracker.upper() == "LCD": lcd = LCD(config=config) - await lcd.edit_desc(meta) + await lcd.edit_desc(meta) + if manual_tracker.upper() == "CBR": + cbr = CBR(config=config) + await cbr.edit_desc(meta) archive_url = await prep.package(meta) upload_embed_description = upload_embed_description.replace('MANUAL', '~~MANUAL~~') - if archive_url == False: + if archive_url is False: upload_embed = discord.Embed(title=f"Uploaded `{meta['name']}` to:", description=upload_embed_description, color=0xff0000) upload_embed.add_field(name="Unable to upload prep files", value=f"The files can be found at `tmp/{meta['title']}.tar`") await msg.edit(embed=upload_embed) else: upload_embed = discord.Embed(title=f"Uploaded `{meta['name']}` to:", description=upload_embed_description, color=0x00ff40) - upload_embed.add_field(name="Files can be found at:",value=f"{archive_url} or `tmp/{meta['uuid']}`") + upload_embed.add_field(name="Files can be found at:", value=f"{archive_url} or `tmp/{meta['uuid']}`") await msg.edit(embed=upload_embed) if "BLU" in tracker_list: blu = BLU(config=config) dupes = await blu.search_existing(meta) meta = await self.dupe_embed(dupes, meta, tracker_emojis, channel) - if meta['upload'] == True: + if meta['upload'] is True: await blu.upload(meta) await client.add_to_client(meta, "BLU") upload_embed_description = upload_embed_description.replace('BLU', '~~BLU~~') upload_embed = discord.Embed(title=f"Uploaded `{meta['name']}` to:", description=upload_embed_description, color=0x00ff40) - await msg.edit(embed=upload_embed) + await msg.edit(embed=upload_embed) if "BHD" in tracker_list: bhd = BHD(config=config) dupes = await bhd.search_existing(meta) meta = await self.dupe_embed(dupes, meta, tracker_emojis, channel) - if meta['upload'] == True: + if meta['upload'] is True: await bhd.upload(meta) await client.add_to_client(meta, "BHD") upload_embed_description = upload_embed_description.replace('BHD', '~~BHD~~') @@ -546,46 +470,54 @@ def check(reaction, user): aither = AITHER(config=config) dupes = await aither.search_existing(meta) meta = await self.dupe_embed(dupes, meta, tracker_emojis, channel) - if meta['upload'] == True: + if meta['upload'] is True: await aither.upload(meta) await client.add_to_client(meta, "AITHER") upload_embed_description = upload_embed_description.replace('AITHER', '~~AITHER~~') upload_embed = discord.Embed(title=f"Uploaded `{meta['name']}` to:", description=upload_embed_description, color=0x00ff40) - await msg.edit(embed=upload_embed) + await msg.edit(embed=upload_embed) if "STC" in tracker_list: stc = STC(config=config) dupes = await stc.search_existing(meta) meta = await self.dupe_embed(dupes, meta, tracker_emojis, channel) - if meta['upload'] == True: + if meta['upload'] is True: await stc.upload(meta) await client.add_to_client(meta, "STC") upload_embed_description = upload_embed_description.replace('STC', '~~STC~~') upload_embed = discord.Embed(title=f"Uploaded `{meta['name']}` to:", description=upload_embed_description, color=0x00ff40) - await msg.edit(embed=upload_embed) + await msg.edit(embed=upload_embed) if "LCD" in tracker_list: lcd = LCD(config=config) dupes = await lcd.search_existing(meta) meta = await self.dupe_embed(dupes, meta, tracker_emojis, channel) - if meta['upload'] == True: + if meta['upload'] is True: await lcd.upload(meta) await client.add_to_client(meta, "LCD") upload_embed_description = upload_embed_description.replace('LCD', '~~LCD~~') upload_embed = discord.Embed(title=f"Uploaded `{meta['name']}` to:", description=upload_embed_description, color=0x00ff40) - await msg.edit(embed=upload_embed) + await msg.edit(embed=upload_embed) + if "CBR" in tracker_list: + cbr = CBR(config=config) + dupes = await cbr.search_existing(meta) + meta = await self.dupe_embed(dupes, meta, tracker_emojis, channel) + if meta['upload'] is True: + await cbr.upload(meta) + await client.add_to_client(meta, "CBR") + upload_embed_description = upload_embed_description.replace('CBR', '~~CBR~~') + upload_embed = discord.Embed(title=f"Uploaded `{meta['name']}` to:", description=upload_embed_description, color=0x00ff40) + await msg.edit(embed=upload_embed) return None - - - + async def dupe_embed(self, dupes, meta, emojis, channel): if not dupes: print("No dupes found") - meta['upload'] = True + meta['upload'] = True return meta else: dupe_text = "\n\n•".join(dupes) dupe_text = f"```•{dupe_text}```" - embed = discord.Embed(title="Are these dupes?", description=dupe_text, color=0xff0000) - embed.set_footer(text=f"{emojis['CANCEL']} to abort upload | {emojis['UPLOAD']} to upload anyways") + embed = discord.Embed(title="Check if these are actually dupes!", description=dupe_text, color=0xff0000) + embed.set_footer(text=f"{emojis['CANCEL']} to abort upload | {emojis['UPLOAD']} to upload anyways") message = await channel.send(embed=embed) await message.add_reaction(emojis['CANCEL']) await asyncio.sleep(0.3) @@ -593,7 +525,7 @@ async def dupe_embed(self, dupes, meta, emojis, channel): def check(reaction, user): if reaction.message.id == message.id: - if str(user.id) == config['DISCORD']['admin_id']: + if str(user.id) == config['DISCORD']['admin_id']: if str(reaction.emoji) == emojis['UPLOAD']: return reaction if str(reaction.emoji) == emojis['CANCEL']: @@ -607,7 +539,7 @@ def check(reaction, user): try: await channel.send(f"{meta['uuid']} timed out") meta['upload'] = False - except: + except Exception: return except CancelException: await channel.send(f"{meta['title']} cancelled") @@ -627,19 +559,18 @@ async def get_missing(self, meta): missing.append('--imdb') if isinstance(meta['potential_missing'], list) and len(meta['potential_missing']) > 0: for each in meta['potential_missing']: - if meta.get(each, '').replace(' ', '') == "": + if meta.get(each, '').replace(' ', '') == "": missing.append(f"--{each}") return missing + def setup(bot): bot.add_cog(Commands(bot)) - - - class CancelException(Exception): pass + class ManualException(Exception): pass diff --git a/cogs/redaction.py b/cogs/redaction.py new file mode 100644 index 000000000..f3d3cbabe --- /dev/null +++ b/cogs/redaction.py @@ -0,0 +1,60 @@ +import re +import json + +SENSITIVE_KEYS = { + "token", "passkey", "password", "auth", "cookie", "csrf", "email", "username", "user", "key", "info_hash", "AntiCsrfToken" +} + + +def redact_value(val): + """Redact sensitive values, including passkeys in URLs.""" + if isinstance(val, str): + # Redact passkeys in announce URLs (e.g. //announce) + val = re.sub(r'(?<=/)[a-zA-Z0-9]{10,}(?=/announce)', '[REDACTED]', val) + # Redact query params like ?passkey=... or &token=... + val = re.sub(r'([?&](passkey|key|token|auth|info_hash)=)[^&]+', r'\1[REDACTED]', val, flags=re.I) + # Redact long hex or base64-like strings (common for tokens) + val = re.sub(r'\b[a-fA-F0-9]{32,}\b', '[REDACTED]', val) + return val + + +def redact_private_info(data, sensitive_keys=SENSITIVE_KEYS): + """Recursively redact sensitive info in dicts/lists.""" + if isinstance(data, dict): + return { + k: ( + "[REDACTED]" if any(s in k.lower() for s in sensitive_keys) + else redact_private_info(v, sensitive_keys) + ) + for k, v in data.items() + } + elif isinstance(data, list): + return [redact_private_info(item, sensitive_keys) for item in data] + elif isinstance(data, str): + return redact_value(data) + else: + return data + + +async def clean_meta_for_export(meta): + """ + Removes all 'status_message' keys from meta['tracker_status'] and + removes or clears 'torrent_comments' from meta. + """ + # tracker status is not in the saved meta file, but adding the catch here + # in case the meta file is updated in the future + if 'tracker_status' in meta and isinstance(meta['tracker_status'], dict): + for tracker in list(meta['tracker_status']): # list() to avoid RuntimeError if deleting keys + if 'status_message' in meta['tracker_status'][tracker]: + del meta['tracker_status'][tracker]['status_message'] + + if 'torrent_comments' in meta: + del meta['torrent_comments'] + + for key in [k for k in meta.keys() if '_secret_token' in k]: + del meta[key] + + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: + json.dump(meta, f, indent=4) + + return meta diff --git a/config-generator.py b/config-generator.py new file mode 100644 index 000000000..0498e9981 --- /dev/null +++ b/config-generator.py @@ -0,0 +1,1071 @@ +#!/usr/bin/env python3 + +import os +import re +import json +from pathlib import Path +import ast + + +def read_example_config(): + """Read the example config file and return its structure and comments""" + example_path = Path("data/example-config.py") + comments = {} + + if not example_path.exists(): + print("[!] Warning: Could not find data/example-config.py") + print("[i] Using built-in default structure instead") + return None, comments + + try: + with open(example_path, "r", encoding="utf-8") as file: + lines = file.readlines() + + current_comments = [] + key_stack = [] + indent_stack = [0] + + for idx, line in enumerate(lines): + line = line.rstrip("\n") + stripped = line.lstrip() + indent = len(line) - len(stripped) + + # Track nesting for fully qualified keys + if "{" in stripped and ":" in stripped: + key = stripped.split(":", 1)[0].strip().strip('"\'') + while indent_stack and indent <= indent_stack[-1]: + key_stack.pop() + indent_stack.pop() + key_stack.append(key) + indent_stack.append(indent) + elif "}" in stripped: + while indent_stack and indent <= indent_stack[-1]: + if key_stack: # Avoid popping from empty list + key_stack.pop() + indent_stack.pop() + + if stripped.startswith("#"): + current_comments.append(stripped) + elif ":" in stripped and not stripped.startswith("{"): + key = stripped.split(":", 1)[0].strip().strip('"\'') + # Build fully qualified key path + fq_key = ".".join(key_stack + [key]) if key_stack else key + + if current_comments: + comments[key] = list(current_comments) + comments[fq_key] = list(current_comments) + current_comments = [] + elif not stripped: # Empty line + pass # Keep the comments for the next key + elif stripped in ["},", "}"]: + pass # Keep the comments for the next key + else: + current_comments = [] # Clear comments on other lines + + # Extract the config dict from the file content + content = ''.join(lines) + match = re.search(r"config\s*=\s*({.*})", content, re.DOTALL) + if not match: + print("[!] Warning: Could not parse example config") + return None, comments + + config_dict_str = match.group(1) + example_config = ast.literal_eval(config_dict_str) + + print("[✓] Successfully loaded example config template") + return example_config, comments + except Exception as e: + print(f"[!] Error parsing example config: {str(e)}") + return None, comments + + +def load_existing_config(): + """Load an existing config file if available""" + config_paths = [ + Path("data/config.py"), + Path("data/config1.py") + ] + + for path in config_paths: + if path.exists(): + try: + with open(path, "r", encoding="utf-8") as file: + content = file.read() + + # Extract the config dict from the file + match = re.search(r"config\s*=\s*({.*})", content, re.DOTALL) + if match: + config_dict_str = match.group(1) + # Convert to proper Python dict + config_dict = ast.literal_eval(config_dict_str) + print(f"\n[✓] Found existing config at {path}") + return config_dict, path + except Exception as e: + print(f"\n[!] Error loading config from {path}: {e}") + + return None, None + + +def validate_config(existing_config, example_config): + """ + Validate the existing config against the example structure. + Returns a cleaned version with only valid keys. + """ + if not existing_config or not example_config: + return existing_config + + unexpected_keys = [] + + # Helper function to find unexpected keys at any level + def find_unexpected_keys(existing_section, example_section, path=""): + if not isinstance(existing_section, dict) or not isinstance(example_section, dict): + return + + for key in existing_section: + current_path = f"{path}.{key}" if path else key + + if key not in example_section: + unexpected_keys.append((current_path, existing_section, key)) + elif isinstance(existing_section[key], dict) and isinstance(example_section.get(key), dict): + # Recursively check nested dictionaries + find_unexpected_keys(existing_section[key], example_section[key], current_path) + + # Check main sections first + for section in existing_config: + if section not in example_config: + unexpected_keys.append((section, existing_config, section)) + elif isinstance(existing_config[section], dict) and isinstance(example_config[section], dict): + # Check keys within valid sections + find_unexpected_keys(existing_config[section], example_config[section], section) + + # If unexpected keys were found, ask about each one individually + if unexpected_keys: + print("\n[!] The following keys in your existing configuration are not in the example config:") + for i, (key_path, parent_dict, key) in enumerate(unexpected_keys): + print(f" {i+1}. {key_path}") + + print("\n\n[i] The keys have been removed or renamed.") + print("[i] You can choose what to do with each key:") + + for i, (key_path, parent_dict, key) in enumerate(unexpected_keys): + value = parent_dict[key] + value_display = str(value) + if isinstance(value, dict): + value_display = "{...}" # Just show placeholder for dictionaries + + # Handle nested structures by limiting display length + if len(value_display) > 50: + value_display = value_display[:47] + "..." + + print(f"\nKey {i+1}/{len(unexpected_keys)}: {key_path} = {value_display}") + keep = input("Keep this key? (y/N): ").lower() + + # Remove the key if user chooses not to keep it + if keep == "y": + print(f"[i] Keeping key: {key_path}") + else: + print(f"[i] Removing key: {key_path}") + del parent_dict[key] + + return existing_config + + # Return original if no unexpected keys + return existing_config + + +def find_missing_keys(existing_config, example_config): + """Find keys that exist in example config but are missing in existing config""" + missing_keys = [] + + # Helper function to find missing keys at any level + def find_missing_recursive(example_section, existing_section, path=""): + if not isinstance(example_section, dict) or not isinstance(existing_section, dict): + return + + for key in example_section: + current_path = f"{path}.{key}" if path else key + + if key not in existing_section: + missing_keys.append(current_path) + elif isinstance(example_section[key], dict) and isinstance(existing_section.get(key), dict): + # Recursively check nested dictionaries + find_missing_recursive(example_section[key], existing_section[key], current_path) + + # Check main sections first + for section in example_config: + if section not in existing_config: + missing_keys.append(section) + elif isinstance(example_config[section], dict) and isinstance(existing_config[section], dict): + # Check keys within valid sections + find_missing_recursive(example_config[section], existing_config[section], section) + + return missing_keys + + +def get_user_input(prompt, default="", is_password=False, is_announce_url=False, existing_value=None): + """Get input from user with default value and optional existing value""" + display = prompt + + # If we have an existing value, show it as an option + if existing_value is not None: + # For password fields: show first 6 chars and mask the rest + if is_password and existing_value: + visible_part = existing_value[:6] + masked_part = "*" * min(8, max(0, len(existing_value) - 6)) + display_value = f"{visible_part}{masked_part}" if len(existing_value) > 6 else existing_value + elif is_announce_url and existing_value: + # For announce_urls, show the first 10 chars and last 6 chars with * in between + if len(existing_value) > 20: # Only mask if long enough + visible_prefix = existing_value[:15] + visible_suffix = existing_value[-6:] + masked_length = len(existing_value) - 16 + masked_part = "*" * min(masked_length, 15) # Limit number of asterisks + display_value = f"{visible_prefix}...{masked_part}...{visible_suffix}" + else: + display_value = existing_value + display = f"{prompt} [existing: {display_value}]" + + # Show default if available + if default and existing_value is None: + display = f"{display} [default: {default}]" + + display = f"{display}: " + + # Prompt for input + value = input(display) + + # Use existing value if user just pressed Enter and we have an existing value + if value == "" and existing_value is not None: + return existing_value + + # Use default if no input and no existing value + if value == "" and default: + return default + + return value + + +def configure_default_section(existing_defaults, example_defaults, config_comments, quick_setup=False): + """ + Helper to configure the DEFAULT section. + Returns a dict with the configured DEFAULT values. + """ + print("\n====== DEFAULT CONFIGURATION ======") + print("\n[i] Press enter to accept the default values/skip, or input your own values.") + config_defaults = {} + + # Settings that should only be prompted if a parent setting has a specific value + linked_settings = { + "update_notification": { + "condition": lambda value: value.lower() == "true", + "settings": ["verbose_notification"] + }, + "tone_map": { + "condition": lambda value: value.lower() == "true", + "settings": ["algorithm", "desat", "tonemapped_header"] + }, + "add_logo": { + "condition": lambda value: value.lower() == "true", + "settings": ["logo_size", "logo_language"] + }, + "frame_overlay": { + "condition": lambda value: value.lower() == "true", + "settings": ["overlay_text_size"] + }, + "multiScreens": { + "condition": lambda value: (value.isdigit() and int(value) > 0), + "settings": ["pack_thumb_size", "charLimit", "fileLimit", "processLimit", ] + }, + "get_bluray_info": { + "condition": lambda value: value.lower() == "true", + "settings": ["add_bluray_link", "use_bluray_images", "bluray_image_size", "bluray_score", "bluray_single_score"] + } + } + + # Store which settings should be skipped based on linked settings + skip_settings = set() + + # If this is a fresh config (no existing defaults), offer quick setup + do_quick_setup = False + if quick_setup: + do_quick_setup = input("\n[i] Do you want to quick setup with just essential settings? (y/N): ").lower() == "y" + if do_quick_setup: + print("[i] Quick setup selected. You'll only be prompted for essential settings.") + + # Define essential settings for quick setup mode + essential_settings = [ + "tmdb_api" + ] + + for key, default_value in example_defaults.items(): + if key in ["default_torrent_client"]: + continue + + # Skip if this setting should be skipped based on linked settings + if key in skip_settings: + # Copy default value from example config + config_defaults[key] = default_value + continue + + # Skip non-essential settings in quick setup mode + if do_quick_setup and key not in essential_settings: + config_defaults[key] = default_value + continue + + if key in config_comments: + print("\n[i] " + "\n[i] ".join(config_comments[key])) + + if isinstance(default_value, bool): + default_str = str(default_value) + existing_value = str(existing_defaults.get(key, default_value)) + value = get_user_input(f"Setting '{key}'? (True/False)", + default=default_str, + existing_value=existing_value) + config_defaults[key] = value + + # Check if this is a linked setting that controls other settings + if key in linked_settings: + linked_group = linked_settings[key] + # If the condition is not met, add all linked settings to the skip list + if not linked_group["condition"](value): + print(f"[i] Skipping {key}-related settings since {key} is {value}") + skip_settings.update(linked_group["settings"]) + else: + is_password = key in ["api_key", "passkey", "rss_key", "tvdb_token", "tmdb_api", "tvdb_api", "btn_api"] or "password" in key.lower() or key.endswith("_key") or key.endswith("_api") or key.endswith("_url") + value = get_user_input( + f"Setting '{key}'", + default=str(default_value), + is_password=is_password, + existing_value=existing_defaults.get(key) + ) + + if default_value is None and (value == "" or value == "None"): + config_defaults[key] = None + else: + config_defaults[key] = value + + if key in linked_settings: + linked_group = linked_settings[key] + if not linked_group["condition"](config_defaults[key]): + print(f"[i] Skipping {key}-related settings since {key} is {config_defaults[key]}") + skip_settings.update(linked_group["settings"]) + + if do_quick_setup: + get_img_host(config_defaults, existing_defaults, example_defaults, config_comments) + print("\n[i] Applied default values from example config for non-essential settings.") + + return config_defaults + + +# Process image hosts +def get_img_host(config_defaults, existing_defaults, example_defaults, config_comments): + img_host_api_map = { + "imgbb": "imgbb_api", + "ptpimg": "ptpimg_api", + "lensdump": "lensdump_api", + "ptscreens": "ptscreens_api", + "onlyimage": "onlyimage_api", + "dalexni": "dalexni_api", + "ziplinestudio": ["zipline_url", "zipline_api_key"], + "passtheimage": "passtheima_ge_api", + "imgbox": None, + "pixhost": None + } + + print("\n==== IMAGE HOST CONFIGURATION ====") + print("[i] Available image hosts: " + ", ".join(img_host_api_map.keys())) + print("[i] Note: imgbox and pixhost don't require API keys") + + # Get existing image hosts if available + existing_hosts = [] + for i in range(1, 11): + key = f"img_host_{i}" + if key in existing_defaults and existing_defaults[key]: + existing_hosts.append(existing_defaults[key].strip().lower()) + + if existing_hosts: + print(f"\n[i] Your existing image hosts: {', '.join(existing_hosts)}") + + try: + default_count = len(existing_hosts) if existing_hosts else 1 + number_hosts = int(input(f"\n[i] How many image hosts would you like to configure? (1-10) [default: {default_count}]: ") or default_count) + number_hosts = max(1, min(10, number_hosts)) # Limit between 1 and 10 + except ValueError: + print(f"[!] Invalid input. Defaulting to {default_count} image host(s).") + number_hosts = default_count + + # Ask for each image host in sequence + for i in range(1, number_hosts + 1): + # Get existing value for this position if available + existing_host = existing_hosts[i-1] if i <= len(existing_hosts) else None + existing_display = f" [existing: {existing_host}]" if existing_host else "" + + valid_host = False + while not valid_host: + host_input = input(f"\n[i] Enter image host #{i}{existing_display} (e.g., ptpimg, imgbb, imgbox): ").strip().lower() + + if host_input == "" and existing_host: + host_input = existing_host + + if host_input in img_host_api_map: + valid_host = True + host_key = f"img_host_{i}" + config_defaults[host_key] = host_input + + # Configure API key(s) for this host, if needed + api_keys = img_host_api_map.get(host_input) + if api_keys is None: + print(f"[i] {host_input} doesn't require an API key.") + continue + + # Convert single string to list for consistent handling + if isinstance(api_keys, str): + api_keys = [api_keys] + + # Process each key for this host + for api_key in api_keys: + if api_key in example_defaults: + if api_key in config_comments: + print("\n[i] " + "\n[i] ".join(config_comments[api_key])) + + is_password = api_key.endswith("_url") or api_key.endswith("_key") or api_key.endswith("_api") + config_defaults[api_key] = get_user_input( + f"Setting '{api_key}' for {host_input}", + default=str(example_defaults.get(api_key, "")), + is_password=is_password, + existing_value=existing_defaults.get(api_key) + ) + else: + print(f"[!] Invalid host: {host_input}. Available hosts: {', '.join(img_host_api_map.keys())}") + + # Set unused image host API keys to empty string + for host, api_key_item in img_host_api_map.items(): + if api_key_item is None: + # Skip hosts that don't need API keys + continue + + if isinstance(api_key_item, str): + api_keys = [api_key_item] + else: + api_keys = api_key_item + + for api_key in api_keys: + if api_key in example_defaults and api_key not in config_defaults: + config_defaults[api_key] = "" + + +def configure_trackers(existing_trackers, example_trackers, config_comments): + """ + Helper to configure the TRACKERS section. + Returns a dict with the configured trackers. + """ + print("\n====== TRACKERS ======") + + # Get list of trackers to configure + example_tracker_list = [ + t for t in example_trackers + if t != "default_trackers" and isinstance(example_trackers[t], dict) + ] + if example_tracker_list: + print(f"[i] Available trackers in example config: \n{', '.join(example_tracker_list)}") + print("\n[i] (default trackers list) Only add the trackers you want to upload to on a regular basis.") + print("[i] You can add other tracker configs later if needed.") + + existing_tracker_list = existing_trackers.get("default_trackers", "").split(",") if existing_trackers.get("default_trackers") else [] + existing_tracker_list = [t.strip() for t in existing_tracker_list if t.strip()] + existing_trackers_str = ", ".join(existing_tracker_list) + + trackers_input = get_user_input( + "\nEnter tracker acronyms separated by commas (e.g. BHD, PTP, AITHER)", + existing_value=existing_trackers_str + ).upper() + trackers_list = [t.strip().upper() for t in trackers_input.split(",") if t.strip()] + + trackers_config = {"default_trackers": ", ".join(trackers_list)} + + # Ask if user wants to update all trackers or specific ones + update_all = input("\n[i] Do you want to update ALL trackers in your default trackers list? (Y/n): ").lower() != "n" + + if not update_all: + # Ask which specific trackers to update + update_specific = input("\nEnter tracker acronyms to update (comma separated), or leave blank to skip all: ").upper() + update_trackers_list = [t.strip() for t in update_specific.split(",") if t.strip()] + else: + # Update all trackers in the list + update_trackers_list = trackers_list.copy() + + # Only update trackers in the update list + for tracker in trackers_list: + # Skip if not in update list (unless updating all) + if not update_all and tracker not in update_trackers_list: + print(f"\nSkipping configuration for {tracker}") + # Copy existing config if available + if tracker in existing_trackers: + trackers_config[tracker] = existing_trackers[tracker] + continue + + print(f"\n\nConfiguring **{tracker}**:") + existing_tracker_config = existing_trackers.get(tracker, {}) + example_tracker = example_trackers.get(tracker, {}) + tracker_config = {} + + if example_tracker and isinstance(example_tracker, dict): + for key, default_value in example_tracker.items(): + # Skip keys that should not be prompted + if tracker == "HDT" and key == "announce_url": + tracker_config[key] = example_tracker[key] + continue + + comment_key = f"TRACKERS.{tracker}.{key}" + if comment_key in config_comments: + print("\n[i] " + "\n[i] ".join(config_comments[comment_key])) + + if isinstance(default_value, bool): + default_str = str(default_value) + existing_value = str(existing_tracker_config.get(key, default_value)) + value = get_user_input(f"Tracker setting '{key}'? (True/False)", + default=default_str, + existing_value=existing_value) + tracker_config[key] = value + else: + is_password = key in ["api_key", "passkey", "rss_key", "password", "opt_uri"] or key.endswith("rss_key") + is_announce_url = key.endswith("announce_url") + tracker_config[key] = get_user_input( + f"Tracker setting '{key}'", + default=str(default_value) if default_value else "", + is_password=is_password, + is_announce_url=is_announce_url, + existing_value=existing_tracker_config.get(key) + ) + else: + print(f"[!] No example config found for tracker '{tracker}'.") + + trackers_config[tracker] = tracker_config + + # Offer to add more trackers from the example config + remaining_trackers = [t for t in example_tracker_list if t.upper() not in [x.upper() for x in trackers_list]] + if remaining_trackers: + print("\n[i] Other trackers available in the example config that are not in your default list:") + print(", ".join(remaining_trackers)) + print("\n[i] This just adds the tracker config, not to your list of default trackers.") + print("\nFor example so you can use with -tk.") + add_more = get_user_input( + "\nEnter any additional tracker acronyms to add (comma separated), or leave blank to skip" + ) + additional = [t.strip().upper() for t in add_more.split(",") if t.strip()] + for tracker in additional: + if tracker in trackers_config: + continue # Already configured + print(f"\n\nConfiguring **{tracker}**:") + example_tracker = example_trackers.get(tracker, {}) + tracker_config = {} + if example_tracker and isinstance(example_tracker, dict): + for key, default_value in example_tracker.items(): + if tracker == "HDT" and key == "announce_url": + tracker_config[key] = example_tracker[key] + continue + comment_key = f"TRACKERS.{tracker}.{key}" + if comment_key in config_comments: + print("\n[i] " + "\n[i] ".join(config_comments[comment_key])) + + if isinstance(default_value, bool): + default_str = str(default_value) + value = get_user_input(f"Tracker setting '{key}'? (True/False)", + default=default_str) + tracker_config[key] = value + else: + is_password = key in ["api_key", "passkey", "rss_key", "password", "opt_uri"] or key.endswith("rss_key") + is_announce_url = key.endswith("announce_url") + tracker_config[key] = get_user_input( + f"Tracker setting '{key}'", + default=str(default_value) if default_value else "", + is_password=is_password, + is_announce_url=is_announce_url + ) + else: + print(f"[!] No example config found for tracker '{tracker}'.") + trackers_config[tracker] = tracker_config + + return trackers_config + + +def configure_torrent_clients(existing_clients=None, example_clients=None, default_client_name=None, config_comments=None): + """ + Helper to configure the TORRENT_CLIENTS section. + Returns a dict with the configured client(s) and the selected default client name. + """ + config_clients = {} + existing_clients = existing_clients or {} + example_clients = example_clients or {} + config_comments = config_comments or {} + + # Only use default_client_name if provided and in existing_clients + if default_client_name and default_client_name in existing_clients: + keep_existing_client = input(f"\nDo you want to keep the existing client '{default_client_name}'? (y/n): ").lower() == "y" + if not keep_existing_client: + print("What client do you want to use instead?") + print("Available clients in example config:") + for client_name in example_clients: + print(f" - {client_name}") + new_client = get_user_input("Enter the name of the torrent client to use", + default="qbittorrent", + existing_value=default_client_name) + default_client_name = new_client + else: + # No default client specified or not in existing_clients, ask user to select one + print("No default client found. Let's configure one.") + print("What client do you want to use?") + print("Available clients in example config:") + for client_name in example_clients: + print(f" - {client_name}") + default_client_name = get_user_input("Enter the name of the torrent client to use", + default="qbittorrent") + + # Configure the default client + print(f"\nConfiguring default client: {default_client_name}") + config_clients = configure_single_client(default_client_name, existing_clients, example_clients, config_clients, config_comments) + + # After configuring the default client, ask if the user wants to add additional clients + while True: + add_another = input("\n\n[i] Do you want to add configuration for another torrent client? (y/N): ").lower() == "y" + if not add_another: + break + + # Show available clients not yet configured + available_clients = [c for c in example_clients if c not in config_clients] + if not available_clients: + print("All available clients from the example config have been configured.") + break + + print("\nAvailable clients to configure:") + for client_name in available_clients: + print(f" - {client_name}") + + additional_client = get_user_input("Enter the name of the torrent client to configure") + if not additional_client: + print("No client name provided, skipping additional client configuration.") + continue + + if additional_client in config_clients: + print(f"Client '{additional_client}' is already configured.") + continue + + if additional_client not in example_clients: + print(f"Client '{additional_client}' not found in example config. Available clients: {', '.join(available_clients)}") + continue + + # Configure the additional client + print(f"\nConfiguring additional client: {additional_client}") + config_clients = configure_single_client(additional_client, existing_clients, example_clients, config_clients, config_comments) + + return config_clients, default_client_name + + +def configure_single_client(client_name, existing_clients, example_clients, config_clients, config_comments): + """Helper function to configure a single torrent client""" + # Use existing config for the selected client if present, else use example config + existing_client_config = existing_clients.get(client_name, {}) + example_client_config = example_clients.get(client_name, {}) + + if not example_client_config: + print(f"[!] No example config found for client '{client_name}'.") + if existing_client_config: + print(f"[i] Using existing config for '{client_name}'") + config_clients[client_name] = existing_client_config + return config_clients + + # Set the client type from the example config + client_type = example_client_config.get("torrent_client", client_name) + client_config = {"torrent_client": client_type} + + # Process all other client settings + for key, default_value in example_client_config.items(): + # this is never edited + if key == "torrent_client": + continue + + comment_key = f"TORRENT_CLIENTS.{client_name}.{key}" + if comment_key in config_comments: + print("\n[i] " + "\n[i] ".join(config_comments[comment_key])) + elif key in config_comments: + print("\n[i] " + "\n[i] ".join(config_comments[key])) + + if isinstance(default_value, bool): + default_str = str(default_value) + existing_value = str(existing_client_config.get(key, default_value)) + value = get_user_input(f"Client setting '{key}'? (True/False)", + default=default_str, + existing_value=existing_value) + client_config[key] = value + else: + is_password = key.endswith("pass") or key.endswith("password") + client_config[key] = get_user_input( + f"Client setting '{key}'", + default=str(default_value) if default_value is not None else "", + is_password=is_password, + existing_value=existing_client_config.get(key) + ) + + config_clients[client_name] = client_config + return config_clients + + +def configure_discord(existing_discord, example_discord, config_comments): + """ + Helper to configure the DISCORD section. + Returns a dict with the configured Discord settings. + """ + print("\n====== DISCORD CONFIGURATION ======") + print("[i] Configure Discord bot settings for upload notifications") + + discord_config = {} + existing_use_discord = existing_discord.get("use_discord", False) + enable_discord = get_user_input( + "Enable Discord bot functionality? (True/False)", + default="False", + existing_value=str(existing_use_discord) + ) + discord_config["use_discord"] = enable_discord + + # If Discord is disabled, set defaults and return + if enable_discord.lower() != "true": + print("[i] Discord disabled. Setting default values for other Discord settings.") + for key, default_value in example_discord.items(): + if key != "use_discord": + discord_config[key] = default_value + return discord_config + + # Configure other Discord settings if enabled + for key, default_value in example_discord.items(): + if key == "use_discord": + continue + + comment_key = f"DISCORD.{key}" + if comment_key in config_comments: + print("\n[i] " + "\n[i] ".join(config_comments[comment_key])) + + if isinstance(default_value, bool): + default_str = str(default_value) + existing_value = str(existing_discord.get(key, default_value)) + value = get_user_input( + f"Discord setting '{key}'? (True/False)", + default=default_str, + existing_value=existing_value + ) + discord_config[key] = value + else: + is_password = key in ["discord_bot_token"] + discord_config[key] = get_user_input( + f"Discord setting '{key}'", + default=str(default_value) if default_value else "", + is_password=is_password, + existing_value=existing_discord.get(key) + ) + + return discord_config + + +def generate_config_file(config_data, existing_path=None): + """Generate the config.py file from the config dictionary""" + # Create output directory if it doesn't exist + os.makedirs("data", exist_ok=True) + + # Determine the output path + if existing_path: + config_path = existing_path + backup_path = Path(f"{existing_path}.bak") + # Create backup of existing config + if existing_path.exists(): + with open(existing_path, "r", encoding="utf-8") as src: + with open(backup_path, "w", encoding="utf-8") as dst: + dst.write(src.read()) + print(f"\n[✓] Created backup of existing config at {backup_path}") + else: + config_path = Path("data/config.py") + backup_path = Path("data/config.py.bak") + if config_path.exists(): + overwrite = input(f"{config_path} already exists. Overwrite? (y/n): ").lower() + if overwrite != "y": + with open(config_path, "r", encoding="utf-8") as src: + with open(backup_path, "w", encoding="utf-8") as dst: + dst.write(src.read()) + print(f"\n[✓] Created backup of existing config at {backup_path}") + + # Convert boolean values in config to proper Python booleans + def format_config(obj): + if isinstance(obj, dict): + # Process each key-value pair in dictionaries + return {k: format_config(v) for k, v in obj.items()} + elif isinstance(obj, list): + # Process each item in lists + return [format_config(item) for item in obj] + elif isinstance(obj, str): + # Convert string "true"/"false" to Python True/False + if obj.lower() == "true": + return True + elif obj.lower() == "false": + return False + # Return unchanged for other types + return obj + + # Format config with proper Python booleans + formatted_config = format_config(config_data) + + # Generate the config file with properly formatted Python syntax + with open(config_path, "w", encoding="utf-8") as file: + file.write("config = {\n") + + # Custom formatting function to create Python dict with trailing commas + def write_dict(d, indent_level=1): + indent = " " * indent_level + for key, value in d.items(): + file.write(f"{indent}{json.dumps(key)}: ") + + if isinstance(value, dict): + file.write("{\n") + write_dict(value, indent_level + 1) + file.write(f"{indent}}},\n") + elif isinstance(value, bool): + # Ensure booleans are capitalized + file.write(f"{str(value).capitalize()},\n") + elif isinstance(value, type(None)): + # Handle None values + file.write("None,\n") + else: + # Other values with trailing comma + file.write(f"{json.dumps(value, ensure_ascii=False)},\n") + + write_dict(formatted_config) + file.write("}\n") + + print(f"\n[✓] Configuration file created at {config_path}") + return True + + +if __name__ == "__main__": + print("\nUpload Assistant Configuration Generator") + print("========================================") + + # Get example configuration structure first + example_config, config_comments = read_example_config() + + # Try to load existing config + existing_config, existing_path = load_existing_config() + + if existing_config and example_config: + just_updating = input("\nExisting config found. Are you just updating to grab any new UA config options? (Y/n): ").lower() + if just_updating == "n": + use_existing = input("\nWould you like to edit existing instead of starting fresh? (Y/n): ").lower() + if use_existing == "n": + print("\n[i] Starting with fresh configuration.") + print("Enter to accept the default values/skip, or enter your own values.") + config_data = {} + + # DEFAULT section + example_defaults = example_config.get("DEFAULT", {}) + config_data["DEFAULT"] = configure_default_section({}, example_defaults, config_comments, quick_setup=True) + # Set default client name if not set + config_data["DEFAULT"]["default_torrent_client"] = config_data["DEFAULT"].get("default_torrent_client", "qbittorrent") + + # TRACKERS section + example_trackers = example_config.get("TRACKERS", {}) + config_data["TRACKERS"] = configure_trackers({}, example_trackers, config_comments) + + # TORRENT_CLIENTS section + example_clients = example_config.get("TORRENT_CLIENTS", {}) + default_client = None + client_configs, default_client = configure_torrent_clients( + {}, example_clients, default_client, config_comments + ) + config_data["TORRENT_CLIENTS"] = client_configs + config_data["DEFAULT"]["default_torrent_client"] = default_client + + example_discord = example_config.get("DISCORD", {}) + config_data["DISCORD"] = configure_discord({}, example_discord, config_comments) + + generate_config_file(config_data) + else: + print("\n[i] Using existing configuration as a template.") + print("[i] Existing config will be renamed config.py.bak.") + print("[i] Press enter to accept the default values/skip, or input your own values.") + + # Check for unexpected keys in existing config + existing_config = validate_config(existing_config, example_config) + + # Start with the existing config + config_data = existing_config.copy() + + # Ask about updating each main section separately + print("\n\n[i] Lets work on one section at a time.") + print() + + # DEFAULT section + update_default = input("Do you want to update something in the DEFAULT section? (y/n): ").lower() == "y" + if update_default: + existing_defaults = existing_config.get("DEFAULT", {}) + example_defaults = example_config.get("DEFAULT", {}) + config_data["DEFAULT"] = configure_default_section(existing_defaults, example_defaults, config_comments) + # Set default client name (if needed) + config_data["DEFAULT"]["default_torrent_client"] = config_data["DEFAULT"].get("default_torrent_client", "qbittorrent") + else: + print("[i] Keeping existing DEFAULT section") + print() + + # TRACKERS section + update_trackers = input("Do you want to update something in the TRACKERS section? (y/n): ").lower() == "y" + if update_trackers: + existing_trackers = existing_config.get("TRACKERS", {}) + example_trackers = example_config.get("TRACKERS", {}) + config_data["TRACKERS"] = configure_trackers(existing_trackers, example_trackers, config_comments) + else: + print("[i] Keeping existing TRACKERS section") + print() + + # TORRENT_CLIENTS section + update_clients = input("\nDo you want to update something in the TORRENT_CLIENTS section? (y/n): ").lower() == "y" + if update_clients: + print("\n====== TORRENT CLIENT ======") + existing_clients = existing_config.get("TORRENT_CLIENTS", {}) + example_clients = example_config.get("TORRENT_CLIENTS", {}) + default_client = config_data["DEFAULT"].get("default_torrent_client", None) + + # Get updated client config and default client name + client_configs, default_client = configure_torrent_clients( + existing_clients, example_clients, default_client, config_comments + ) + + # Update client configs and default client name + config_data["TORRENT_CLIENTS"] = client_configs + config_data["DEFAULT"]["default_torrent_client"] = default_client + else: + print("[i] Keeping existing TORRENT_CLIENTS section") + print() + + # DISCORD section update + update_discord = input("Do you want to update something in the DISCORD section? (y/n): ").lower() == "y" + if update_discord: + existing_discord = existing_config.get("DISCORD", {}) + example_discord = example_config.get("DISCORD", {}) + config_data["DISCORD"] = configure_discord(existing_discord, example_discord, config_comments) + else: + print("[i] Keeping existing DISCORD section") + print() + + missing_discord_keys = [] + missing_default_keys = [] + if "DEFAULT" in example_config and "DEFAULT" in config_data: + def find_missing_default_keys(example_section, existing_section, path=""): + for key in example_section: + if key not in existing_section: + missing_default_keys.append(key) + find_missing_default_keys(example_config["DEFAULT"], config_data["DEFAULT"]) + + if missing_default_keys: + print("\n\n[!] Your existing config is missing these keys from example-config:") + + # Only prompt for the missing keys + missing_defaults = {k: example_config["DEFAULT"][k] for k in missing_default_keys} + # Use empty dict for existing values so only defaults are shown + added_defaults = configure_default_section({}, missing_defaults, config_comments) + config_data["DEFAULT"].update(added_defaults) + + if "DISCORD" in example_config: + if "DISCORD" not in config_data: + # Entire DISCORD section is missing + print("\n[!] DISCORD section is missing from your config") + add_discord = input("Do you want to add Discord configuration? (y/n): ").lower() == "y" + if add_discord: + example_discord = example_config.get("DISCORD", {}) + config_data["DISCORD"] = configure_discord({}, example_discord, config_comments) + else: + config_data["DISCORD"] = example_config["DISCORD"].copy() + else: + # Check for missing keys within DISCORD section + def find_missing_discord_keys(example_section, existing_section): + for key in example_section: + if key not in existing_section: + missing_discord_keys.append(key) + find_missing_discord_keys(example_config["DISCORD"], config_data["DISCORD"]) + + if missing_discord_keys: + print(f"\n[!] Your DISCORD config is missing these keys: {', '.join(missing_discord_keys)}") + add_missing_discord = input("Do you want to configure the missing Discord settings? (y/n): ").lower() == "y" + if add_missing_discord: + missing_discord_config = {k: example_config["DISCORD"][k] for k in missing_discord_keys} + added_discord = configure_discord({}, missing_discord_config, config_comments) + config_data["DISCORD"].update(added_discord) + else: + for key in missing_discord_keys: + config_data["DISCORD"][key] = example_config["DISCORD"][key] + + # Generate the updated config file + generate_config_file(config_data, existing_path) + else: + existing_config = validate_config(existing_config, example_config) + config_data = existing_config.copy() + missing_default_keys = [] + if "DEFAULT" in example_config and "DEFAULT" in config_data: + def find_missing_default_keys(example_section, existing_section, path=""): + for key in example_section: + if key not in existing_section: + missing_default_keys.append(key) + find_missing_default_keys(example_config["DEFAULT"], config_data["DEFAULT"]) + + if missing_default_keys: + print("\n[!] Your existing config is missing these keys from example-config:") + + # Only prompt for the missing keys + missing_defaults = {k: example_config["DEFAULT"][k] for k in missing_default_keys} + added_defaults = configure_default_section({}, missing_defaults, config_comments) + config_data["DEFAULT"].update(added_defaults) + + if "DISCORD" not in config_data and "DISCORD" in example_config: + print("\n[!] DISCORD section is missing from your config") + config_data["DISCORD"] = example_config["DISCORD"].copy() + print("[i] Added DISCORD section with default values") + elif "DISCORD" in config_data and "DISCORD" in example_config: + # Check for missing DISCORD keys + missing_discord_keys = [] + for key in example_config["DISCORD"]: + if key not in config_data["DISCORD"]: + missing_discord_keys.append(key) + + if missing_discord_keys: + print(f"\n[!] Your DISCORD config is missing these keys: {', '.join(missing_discord_keys)}") + for key in missing_discord_keys: + config_data["DISCORD"][key] = example_config["DISCORD"][key] + print("[i] Added missing DISCORD keys with default values") + + # Generate the updated config file + generate_config_file(config_data, existing_path) + + else: + print("\n[i] No existing configuration found. Creating a new one.") + print("[i] Enter to accept the default values/skip, or enter your own values.") + + config_data = {} + + # DEFAULT section + example_defaults = example_config.get("DEFAULT", {}) + config_data["DEFAULT"] = configure_default_section({}, example_defaults, config_comments, quick_setup=True) + # Set default client name if not set + config_data["DEFAULT"]["default_torrent_client"] = config_data["DEFAULT"].get("default_torrent_client", "qbittorrent") + + # TRACKERS section + example_trackers = example_config.get("TRACKERS", {}) + config_data["TRACKERS"] = configure_trackers({}, example_trackers, config_comments) + + # TORRENT_CLIENTS section + example_clients = example_config.get("TORRENT_CLIENTS", {}) + default_client = None + client_configs, default_client = configure_torrent_clients( + {}, example_clients, default_client, config_comments + ) + config_data["TORRENT_CLIENTS"] = client_configs + config_data["DEFAULT"]["default_torrent_client"] = default_client + + # DISCORD section + example_discord = example_config.get("DISCORD", {}) + config_data["DISCORD"] = configure_discord({}, example_discord, config_comments) + + generate_config_file(config_data) diff --git a/data/Upload-Assistant-release_notes.md b/data/Upload-Assistant-release_notes.md new file mode 100644 index 000000000..ebe79fcf6 --- /dev/null +++ b/data/Upload-Assistant-release_notes.md @@ -0,0 +1,25 @@ +v6.0.0 + +## RELEASE NOTES + - Immense thanks to @wastaken7 for refactoring the unit3d based tracker code. A huge QOL improvement that removed thousands of lines of code. + - To signify the continued contributions by @wastaken7, this project is now know simply as "Upload Assistant". + - New package added, run requirements.txt + - This release contains lengthy refactoring of many code aspects. Many users, with thanks, have been testing the changes and giving feedback. + - The version bump to v6.0.0 signifies the large code changes, and you should follow an update process suitable for yourself with a major version bump. + +## New config options - see example.py + - FFMPEG related options that may assist those having issues with screenshots. + - AvistaZ based sites have new options in their site sections. +- "use_italian_title" inside SHRI config, for using Italian titles where available +- Some HDT related config options were updated/changed +- "check_predb" for also checking predb for scene status +- "get_bluray_info" updated to also include getting DVD data +- "qui_proxy_url" inside qbittorrent client config, for supporting qui reverse proxy url + +## WHAT'S NEW - some from last release + - New arg -sort, used for sorting filelist, to ensure UA can run with some anime folders that have allowed smaller files. + - New arg -rtk, which can be used to process a run, removing specific trackers from your default trackers list, and processing with the remaining trackers in your default list. + - A significant chunk of the actual upload process has been correctly asynced. Some specific site files still need to be updated and will slow the process. + - More UNIT3D based trackers have been updated with request searching support. + - Added support for sending applicable edition to LST api edition endpoint. + - NoGrp type tags are not removed by default. Use "--no-tag" if desired, and/or report trackers as needed. diff --git a/data/example-config.py b/data/example-config.py index c1b37e8dc..8da0b65ce 100644 --- a/data/example-config.py +++ b/data/example-config.py @@ -1,301 +1,913 @@ config = { - "DEFAULT" : { - - # ------ READ THIS ------ - # Any lines starting with the # symbol are commented and will not be used. - # If you change any of these options, remove the # - # ----------------------- - - "tmdb_api" : "tmdb_api key", - "imgbb_api" : "imgbb api key", - "ptpimg_api" : "ptpimg api key", - "lensdump_api" : "lensdump api key", - - # Order of image hosts, and backup image hosts - "img_host_1": "imgbb", - "img_host_2": "ptpimg", - "img_host_3": "imgbox", - "img_host_4": "pixhost", - "img_host_5": "lensdump", - - - "screens" : "6", - # Enable lossless PNG Compression (True/False) - "optimize_images" : True, + "DEFAULT": { + # will print a notice if an update is available + "update_notification": True, + # will print the changelog if an update is available + "verbose_notification": False, + + # tmdb api key **REQUIRED** + # visit "https://www.themoviedb.org/settings/api" copy api key and insert below + "tmdb_api": "", + + # tvdb api key + # visit "https://www.thetvdb.com/dashboard/account/apikey" copy api key and insert below + "tvdb_api": "", + + # visit "https://thetvdb.github.io/v4-api/#/Login/post_login" enter api key, generate token and insert token below + # the pin in the login form is not needed (don't modify), only enter your api key + "tvdb_token": "", + + # btn api key used to get details from btn + "btn_api": "", + + # Order of image hosts. primary host as first with others as backup + # Available image hosts: imgbb, ptpimg, imgbox, pixhost, lensdump, ptscreens, onlyimage, dalexni, zipline, passtheimage + "img_host_1": "", + "img_host_2": "", + "img_host_3": "", + "img_host_4": "", + "img_host_5": "", + + # image host api keys + "imgbb_api": "", + "ptpimg_api": "", + "lensdump_api": "", + "ptscreens_api": "", + "onlyimage_api": "", + "dalexni_api": "", + "passtheima_ge_api": "", + # custom zipline url + "zipline_url": "", + "zipline_api_key": "", + + # Whether to add a logo for the show/movie from TMDB to the top of the description + "add_logo": False, + + # Logo image size + "logo_size": "300", + + # logo language (ISO 639-1) - default is 'en' (English) + # If a logo with this language cannot be found, English will be used instead + "logo_language": "", + + # set true to add episode overview to description + "episode_overview": False, + + # Number of screenshots to capture + "screens": "4", + + # Number of cutoff screenshots + # If there are at least this many screenshots already, perhaps pulled from existing + # description, skip creating and uploading any further screenshots. + "cutoff_screens": "4", + + # Providing the option to change the size of the screenshot thumbnails where supported. + # Default is 350, ie [img=350] + "thumbnail_size": "350", + + # Number of screenshots per row in the description. Default is single row. + # Only for sites that use common description for now + "screens_per_row": "", + + # Overlay Frame number/type and "Tonemapped" if applicable to screenshots + "frame_overlay": False, + + # Overlay text size (scales with resolution) + "overlay_text_size": "18", + + # Tonemap HDR - DV+HDR screenshots + "tone_map": True, + + # Set false to disable libtorrent ffmpeg tonemapping and use ffmpeg only + "use_libplacebo": True, + + # Set true to skip ffmpeg check, useful if you know your ffmpeg is compatible with libplacebo + # Else, when tonemapping is enabled (and used), UA will run a quick check before to decide + "ffmpeg_is_good": False, + + # Set true to skip "warming up" libplacebo + # Some systems are slow to compile libtorrent shaders, which will cause the first screenshot to fail + "ffmpeg_warmup": False, + + # Tonemap screenshots with the following settings (doesn't apply when using libplacebo) + # See https://ayosec.github.io/ffmpeg-filters-docs/7.1/Filters/Video/tonemap.html + "algorithm": "mobius", + "desat": "10.0", + + # Add this header above screenshots in description when screens have been tonemapped (in bbcode) + "tonemapped_header": "[center][code] Screenshots have been tonemapped for reference [/code][/center]", + + # MULTI PROCESSING + # The optimization task is resource intensive. + # The final value used will be the lowest value of either 'number of screens' + # or this value. Recommended value is enough to cover your normal number of screens. + # If you're on a shared seedbox you may want to limit this to avoid hogging resources. + "process_limit": "4", + + # When optimizing images, limit to this many threads spawned by each process above. + # Recommended value is the number of logical processors on your system. + # This is equivalent to the old shared_seedbox setting, however the existing process + # only used a single process. You probably need to limit this to 1 or 2 to avoid hogging resources. + "threads": "10", + # Set true to limit the amount of CPU when running ffmpeg. + "ffmpeg_limit": False, - # The name of your default torrent client, set in the torrent client sections below - "default_torrent_client" : "Client1", + # Number of screenshots to use for each (ALL) disc/episode when uploading packs to supported sites. + # 0 equals old behavior where only the original description and images are added. + # This setting also affects PTP, however PTP requires at least 2 images for each. + # PTP will always use a *minimum* of 2, regardless of what is set here. + "multiScreens": "2", + + # The next options for packed content do not effect PTP. PTP has a set standard. + # When uploading packs, you can specify a different screenshot thumbnail size, default 300. + "pack_thumb_size": "300", + + # Description character count (including bbcode) cutoff for UNIT3D sites when **season packs only**. + # After hitting this limit, only filenames and screenshots will be used for any ADDITIONAL files + # still to be added to the description. You can set this small like 50, to only ever + # print filenames and screenshots for each file, no mediainfo will be printed. + # UNIT3D sites have a hard character limit for descriptions. A little over 17000 + # worked fine in a forum post at AITHER. If the description is at 1 < charLimit, the next full + # description will be added before respecting this cutoff. + "charLimit": "14000", + + # How many files in a season pack will be added to the description before using an additional spoiler tag. + # Any other files past this limit will be hidden/added all within a spoiler tag. + "fileLimit": "2", + + # Absolute limit on processed files in packs. + # You might not want to process screens/mediainfo for 40 episodes in a season pack. + "processLimit": "10", + + # Providing the option to add a description header, in bbcode, at the top of the description section + # where supported + "custom_description_header": "", + + # Providing the option to add a header, in bbcode, above the screenshot section where supported + "screenshot_header": "", + + # Enable lossless PNG Compression (True/False) + "optimize_images": True, + + # Which client are you using. + "default_torrent_client": "qbittorrent", # Play the bell sound effect when asking for confirmation - "sfx_on_prompt" : True, + "sfx_on_prompt": True, + + # How many trackers need to pass successful checking to continue with the upload process + # Default = 1. If 1 (or more) tracker/s pass banned_group, content and dupe checking, uploading will continue + # If less than the number of trackers pass the checking, exit immediately. + "tracker_pass_checks": "1", + + # Set to true to always just use the largest playlist on a blu-ray, without selection prompt. + "use_largest_playlist": False, + + # Set False to skip getting images from tracker descriptions + "keep_images": True, + + # set true to only grab meta id's from trackers, not descriptions + "only_id": False, + + # set true to use sonarr for tv show searching + "use_sonarr": False, + "sonarr_url": "http://localhost:8989", + "sonarr_api_key": "", + + # details for a second sonarr instance + # additional sonarr instances can be added by adding more sonarr_url_x and sonarr_api_key_x entries + "sonarr_url_1": "http://my-second-instance:8989", + "sonarr_api_key_1": "", + + # set true to use radarr for movie searching + "use_radarr": False, + "radarr_url": "http://localhost:7878", + "radarr_api_key": "", + + # details for a second radarr instance + # additional radarr instances can be added by adding more radarr_url_x and radarr_api_key_x entries + "radarr_url_1": "http://my-second-instance:7878", + "radarr_api_key_1": "", + + # set true to use mkbrr for torrent creation + "mkbrr": True, + + # Create using a specific number of worker threads for hashing (e.g., 8) with mkbrr + # Experimenting with different values might yield better performance than the default automatic setting. + # Conversely, you can set a lower amount such as 1 to protect system resources (default "0" (auto)) + "mkbrr_threads": "0", + + # set true to use argument overrides from data/templates/user-args.json + "user_overrides": False, + + # set true to skip automated client torrent searching + # this will search qbittorrent clients for matching torrents + # and use found torrent id's for existing hash and site searching + 'skip_auto_torrent': False, + + # If there is no region/distributor ids specified, we can use existing torrents to check + # This will use data from matching torrents in qBitTorrent/RuTorrent to find matching site ids + # and then try and find region/distributor ids from those sites + # Requires "skip_auto_torrent" to be set to False + "ping_unit3d": False, + + # If processing a dvd/bluray disc, get related information from bluray.com + # This will set region and distribution info + # Must have imdb id to work + "get_bluray_info": False, + + # Add bluray.com link to description + # Requires "get_bluray_info" to be set to True + "add_bluray_link": False, + # Add cover/back/slip images from bluray.com to description if available + # Requires "get_bluray_info" to be set to True + "use_bluray_images": False, + + # Size of bluray.com cover images. + # bbcode is width limited, cover images are mostly hight dominant + # So you probably want a smaller size than screenshots for instance + "bluray_image_size": "250", + + # A release with 100% score will have complete matching details between bluray.com and bdinfo + # Each missing Audio OR Subtitle track will reduce the score by 5 + # Partial matched audio tracks have a 2.5 score penalty + # If only a single bdinfo audio/subtitle track, penalties are doubled + # Video codec/resolution and disc size mismatches have huge penalities + # Only useful in unattended mode. If not unattended you will be prompted to confirm release + # Final score must be greater than this value to be considered a match + # Only works with blu-ray discs, not dvd + "bluray_score": 94.5, + + # If there is only a single release on bluray.com, you may wish to relax the score a little + "bluray_single_score": 89.5, + + # NOT RECOMMENDED UNLESS YOU KNOW WHAT YOU ARE DOING + # set true to not delete existing meta.json file before running + "keep_meta": False, + + # Set true to print the tracker api messages from uploads + "print_tracker_messages": False, + + # Whether or not to print direct torrent links for the uploaded content + "print_tracker_links": True, + + # Add a directory for Emby linking. This is the folder where the emby files will be linked to. + # If not set, Emby linking will not be performed. Symlinking only, linux not tested + # path in quotes (double quotes for windows), e.g. "C:\\Emby\\Movies" + # this path for movies + "emby_dir": None, + + # this path for TV shows + "emby_tv_dir": None, + + # Set true to search for matching requests on supported trackers + "search_requests": False, + + # Set true to also try searching predb for scene release + # predb is not consistent, can timeout, but can find some releases not found on SRRDB + "check_predb": False, + + }, + + # these are used for DB links on AR + "IMAGES": { + "imdb_75": 'https://i.imgur.com/Mux5ObG.png', + "tmdb_75": 'https://i.imgur.com/r3QzUbk.png', + "tvdb_75": 'https://i.imgur.com/UWtUme4.png', + "tvmaze_75": 'https://i.imgur.com/ZHEF5nE.png', + "mal_75": 'https://i.imgur.com/PBfdP3M.png' }, - "TRACKERS" : { + "TRACKERS": { # Which trackers do you want to upload to? - "default_trackers" : "BLU, BHD, AITHER, STC, STT, SN, THR, R4E, HP, ACM, PTP, LCD, LST, PTER, NBL, ANT, MTV", + # Available tracker: ACM, AITHER, AL, ANT, AR, ASC, AZ, BHD, BHDTV, BJS, BLU, BT, CBR, CZ, DC, DP, FF, FL, FNP, FRIKI, GPW, HDB, HDS, HDT, HHD, HUNO, ITT, LCD, LDU, LST, LT, MTV, NBL, OE, OTW, PHD, PT, PTER, PTP, PTS, PTT, R4E, RAS, RF, RTF, SAM, SHRI, SN, SP, SPD, STC, THR, TIK, TL, TTG, TVC, UHD, ULCX, UTP, YOINK, YUS + # Only add the trackers you want to upload to on a regular basis + "default_trackers": "", - "BLU" : { - "useAPI" : False, # Set to True if using BLU - "api_key" : "BLU api key", - "announce_url" : "https://blutopia.cc/announce/customannounceurl", - # "anon" : False - }, - "BHD" : { - "api_key" : "BHD api key", - "announce_url" : "https://beyond-hd.me/announce/customannounceurl", - "draft_default" : "True", - # "anon" : False + "ACM": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "announce_url": "https://eiga.moi/announce/customannounceurl", + "anon": False, + }, + "AITHER": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + "api_key": "", + "anon": False, + # Send uploads to Aither modq for staff approval + "modq": False, + }, + "AL": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "ANT": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "announce_url": "https://anthelion.me/announce/customannounceurl", + "anon": False, + }, + "AR": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # anon is not an option when uploading you need to change your privacy settings. + "username": "", + "password": "", + "announce_url": "http://tracker.alpharatio.cc:2710/PASSKEY/announce", + }, + "ASC": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # Set uploader_status to True if you have uploader permissions to automatically approve your uploads + "uploader_status": False, + # The custom layout default is 2 + # If you have a custom layout, you'll need to inspect the element on the upload page to find the correct layout value + # Don't change it unless you know what you're doing + "custom_layout": '2', + # anon is not an option when uploading to ASC + # for ASC to work you need to export cookies from https://cliente.amigos-share.club/ using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/ + # cookies need to be in netscape format and need to be in data/cookies/ASC.txt + "announce_url": "https://amigos-share.club/announce.php?passkey=PASSKEY" + }, + "AZ": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # for AZ to work you need to export cookies from https://avistaz.to using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/ + # cookies need to be in netscape format and need to be in data/cookies/AZ.txt + "announce_url": "https://tracker.avistaz.to//announce", + "anon": False, + # If True, the script performs a basic rules compliance check (e.g., codecs, region). + # This does not cover all tracker rules. Set to False to disable. + "check_for_rules": True, + }, + "BHD": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + "api_key": "", + "bhd_rss_key": "", + "announce_url": "https://beyond-hd.me/announce/customannounceurl", + # Send uploads to BHD drafts + "draft_default": "False", + "anon": False, }, "BHDTV": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", "api_key": "found under https://www.bit-hdtv.com/my.php", "announce_url": "https://trackerr.bit-hdtv.com/announce", - #passkey found under https://www.bit-hdtv.com/my.php + # passkey found under https://www.bit-hdtv.com/my.php "my_announce_url": "https://trackerr.bit-hdtv.com/passkey/announce", - # "anon" : "False" - }, - "PTP" : { - "useAPI" : False, # Set to True if using PTP - "add_web_source_to_desc" : True, - "ApiUser" : "ptp api user", - "ApiKey" : 'ptp api key', - "username" : "", - "password" : "", - "announce_url" : "" - }, - "AITHER" :{ - "api_key" : "AITHER api key", - "announce_url" : "https://aither.cc/announce/customannounceurl", - # "anon" : False - }, - "R4E" :{ - "api_key" : "R4E api key", - "announce_url" : "https://racing4everyone.eu/announce/customannounceurl", - # "anon" : False - }, - "HUNO" : { - "api_key" : "HUNO api key", - "announce_url" : "https://hawke.uno/announce/customannounceurl", - # "anon" : False + "anon": False, + }, + "BJS": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # for BJS to work you need to export cookies from https://bj-share.info using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/. + # cookies need to be in netscape format and need to be in data/cookies/BJS.txt + "announce_url": "https://tracker.bj-share.info:2053//announce", + "anon": False, + # Set to False if during an anonymous upload you want your release group to be hidden + "show_group_if_anon": True, + }, + "BLU": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + "api_key": "", + "anon": False, + }, + "BT": { + "link_dir_name": "", + # for BT to work you need to export cookies from https://brasiltracker.org/ using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/. + # cookies need to be in netscape format and need to be in data/cookies/BT.txt + "announce_url": "https://t.brasiltracker.org//announce", + "anon": False, + }, + "CBR": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + # Send uploads to CBR modq for staff approval + "modq": False, + }, + "CZ": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # for CZ to work you need to export cookies from https://cinemaz.to using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/ + # cookies need to be in netscape format and need to be in data/cookies/CZ.txt + "announce_url": "https://tracker.cinemaz.to//announce", + "anon": False, + # If True, the script performs a basic rules compliance check (e.g., codecs, region). + # This does not cover all tracker rules. Set to False to disable. + "check_for_rules": True, + }, + "DC": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # You can find your passkey at Settings -> Security -> Passkey + "passkey": "", + # You can find your api key at Settings -> Security -> API Key -> Generate API Key + "api_key": "", + "anon": False, + }, + "DP": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + # Send uploads to DP modq for staff approval + "modq": False, + }, + "FF": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "username": "", + "password": "", + # You can find your announce URL by downloading any torrent from FunFile, adding it to your client, and then copying the URL from the 'Trackers' tab. + "announce_url": "https://tracker.funfile.org:2711//announce", + # Set to True if you want to check whether your upload fulfills corresponding requests. This may slightly slow down the upload process. + "check_requests": False, + }, + "FL": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "username": "", + "passkey": "", + "uploader_name": "https://filelist.io/Custom_Announce_URL", + "anon": False, + }, + "FNP": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "FRIKI": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + }, + "GPW": { + "link_dir_name": "", + # You can find your API key in Profile Settings -> Access Settings -> API Key. If there is no API, click "Reset your api key" and Save Profile. + "api_key": "", + # Optionally, you can export cookies from GPW to improve duplicate searches. + # If you do this, you must export cookies from https://greatposterwall.com using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/ + # Cookies must be in Netscape format and must be located in data/cookies/GPW.txt + # You can find your announce URL at https://greatposterwall.com/upload.php + "announce_url": "https://tracker.greatposterwall.com//announce", + }, + "HDB": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + # for HDB you **MUST** have been granted uploading approval via Offers, you've been warned + # for HDB to work you need to export cookies from https://hdbits.org/ using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/. + # cookies need to be in netscape format and need to be in data/cookies/HDB.txt + "username": "", + "passkey": "", + "announce_url": "https://hdbits.org/announce/Custom_Announce_URL", + "img_rehost": True, + }, + "HDS": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # for HDS to work you need to export cookies from https://hd-space.org/ using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/. + # cookies need to be in netscape format and need to be in data/cookies/HDS.txt + "announce_url": "http://hd-space.pw/announce.php?pid=", + "anon": False, + }, + "HDT": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # For HDT to work, you need to export cookies from the site using: + # https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/ + # Cookies must be in Netscape format and saved in: data/cookies/HDT.txt + # You can change the URL if the main site is down or if you encounter upload issues. + # Keep in mind that changing the URL requires exporting the cookies again from the new domain. + # Alternative domains: + # - https://hd-torrents.org/ + # - https://hd-torrents.net/ + # - https://hd-torrents.me/ + # - https://hdts.ru/ + "url": "https://hd-torrents.me/", + "anon": False, + "announce_url": "https://hdts-announce.ru/announce.php?pid=", + }, + "HHD": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "HUNO": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "useAPI": False, + "api_key": "", + "anon": False, + }, + "ITT": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "LCD": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "LDU": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "LST": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + "api_key": "", + "anon": False, + # Send uploads to LST modq for staff approval + "modq": False, + # Send uploads to LST drafts + "draft": False, + }, + "LT": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, }, "MTV": { - 'api_key' : 'get from security page', - 'username' : '', - 'password' : '', - 'announce_url' : "get from https://www.morethantv.me/upload.php", - 'anon' : False, - # 'otp_uri' : 'OTP URI, read the following for more information https://github.com/google/google-authenticator/wiki/Key-Uri-Format' - }, - "STC" :{ - "api_key" : "STC", - "announce_url" : "https://skipthecommericals.xyz/announce/customannounceurl", - # "anon" : False - }, - "STT" :{ - "api_key" : "STC", - "announce_url" : "https://stt.xyz/announce/customannounceurl", - # "anon" : False - }, - "SN": { - "api_key": "6Z1tMrXzcYpIeSdGZueQWqb3BowlS6YuIoZLHe3dvIqkSfY0Ws5SHx78oGSTazG0jQ1agduSqe07FPPE8sdWTg", - "announce_url": "https://tracker.swarmazon.club:8443//announce", - }, - "HP" :{ - "api_key" : "HP", - "announce_url" : "https://hidden-palace.net/announce/customannounceurl", - # "anon" : False - }, - "ACM" :{ - "api_key" : "ACM api key", - "announce_url" : "https://asiancinema.me/announce/customannounceurl", - # "anon" : False, - - # FOR INTERNAL USE ONLY: - # "internal" : True, - # "internal_groups" : ["What", "Internal", "Groups", "Are", "You", "In"], - }, - "NBL" : { - "api_key" : "NBL api key", - "announce_url" : "https://nebulance.io/customannounceurl", - }, - "ANT" :{ - "api_key" : "ANT api key", - "announce_url" : "https://anthelion.me/announce/customannounceurl", - # "anon" : False - }, - "THR" : { - "username" : "username", - "password" : "password", - "img_api" : "get this from the forum post", - "announce_url" : "http://www.torrenthr.org/announce.php?passkey=yourpasskeyhere", - "pronfo_api_key" : "pronfo api key", - "pronfo_theme" : "pronfo theme code", - "pronfo_rapi_id" : "pronfo remote api id", - # "anon" : False - }, - "LCD" : { - "api_key" : "LCD api key", - "announce_url" : "https://locadora.cc/announce/customannounceurl", - # "anon" : False - }, - "LST" : { - "api_key" : "LST api key", - "announce_url" : "https://lst.gg/announce/customannounceurl", - # "anon" : False - }, - "LT" : { - "api_key" : "LT api key", - "announce_url" : "https://lat-team.com/announce/customannounceurl", - # "anon" : False - }, - "PTER" : { - "passkey":'passkey', - "img_rehost" : False, - "username" : "", - "password" : "", + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + 'api_key': 'get from security page', + 'username': '', + 'password': '', + 'announce_url': "get from https://www.morethantv.me/upload.php", + 'anon': False, + # read the following for more information https://github.com/google/google-authenticator/wiki/Key-Uri-Format + 'otp_uri': 'OTP URI,', + # Skip uploading to MTV if it would require a torrent rehash because existing piece size > 8 MiB + 'skip_if_rehash': False, + # Iterate over found torrents and prefer MTV suitable torrents if found. + 'prefer_mtv_torrent': False, + }, + "NBL": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "announce_url": "https://tracker.nebulance.io/insertyourpasskeyhere/announce", + }, + "OE": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + "api_key": "", + "anon": False, + }, + "OTW": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + # Send uploads to OTW modq for staff approval + "modq": False, + "anon": False, + }, + "PHD": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # for PHD to work you need to export cookies from https://privatehd.to/ using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/ + # cookies need to be in netscape format and need to be in data/cookies/PHD.txt + "announce_url": "https://tracker.privatehd.to//announce", + "anon": False, + # If True, the script performs a basic rules compliance check (e.g., codecs, region). + # This does not cover all tracker rules. Set to False to disable. + "check_for_rules": True, + }, + "PT": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "PTER": { # Does not appear to be working at all + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "passkey": 'passkey', + "img_rehost": False, + "username": "", + "password": "", "ptgen_api": "", "anon": True, }, - "TL": { - "announce_key": "TL announce key", + "PTP": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + "add_web_source_to_desc": True, + "ApiUser": "ptp api user", + "ApiKey": 'ptp api key', + "username": "", + "password": "", + "announce_url": "", + }, + "PTS": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # for PTS to work you need to export cookies from https://www.ptskit.org using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/. + # cookies need to be in netscape format and need to be in data/cookies/PTS.txt + "announce_url": "https://ptskit.kqbhek.com/announce.php?passkey=", + }, + "PTT": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "R4E": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "announce_url": "https://racing4everyone.eu/announce/customannounceurl", + "anon": False, + }, + "RAS": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "RF": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, }, - "TDC" :{ - "api_key" : "TDC api key", - "announce_url" : "https://thedarkcommunity.cc/announce/customannounceurl", - # "anon" : "False" + "RTF": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "username": "", + "password": "", + # get_it_by_running_/api/ login command from https://retroflix.club/api/doc + "api_key": '', + "announce_url": "get from upload page", + "anon": True, }, - "HDT" : { - "username" : "username", - "password" : "password", - "my_announce_url": "https://hdts-announce.ru/announce.php?pid=", - # "anon" : "False" - "announce_url" : "https://hdts-announce.ru/announce.php", #DO NOT EDIT THIS LINE + "SAM": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, }, - "OE" : { - "api_key" : "OE api key", - "announce_url" : "https://onlyencodes.cc/announce/customannounceurl", - # "anon" : False + "SHRI": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + # Use Italian title instead of English title, if available + "use_italian_title": False, }, - "RTF": { - "api_key": 'get_it_by_running_/api/ login command from https://retroflix.club/api/doc', - "announce_url": "get from upload page", - # "tag": "RetroFlix, nd", - "anon": True + "SN": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "announce_url": "https://tracker.swarmazon.club:8443//announce", }, - "RF" : { - "api_key" : "RF api key", - "announce_url" : "https://reelflix.xyz/announce/customannounceurl", - # "anon" : False + "SP": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + }, + "SPD": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # You can create an API key here https://speedapp.io/profile/api-tokens. Required Permission: Upload torrents + "api_key": "", + # You can find your passkey at your profile (https://speedapp.io/profile) -> Passkey + "passkey": "", + # Select the upload channel, if you don't know what this is, leave it empty. + # You can also set this manually using the args -ch or --channel, without '@'. Example: @spd -> '-ch spd'. + "channel": "", + }, + "STC": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "THR": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "username": "", + "password": "", + "img_api": "get this from the forum post", + "announce_url": "http://www.torrenthr.org/announce.php?passkey=yourpasskeyhere", + "pronfo_api_key": "", + "pronfo_theme": "pronfo theme code", + "pronfo_rapi_id": "pronfo remote api id", + "anon": False, + }, + "TIK": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, }, - "MANUAL" : { - # Uncomment and replace link with filebrowser (https://github.com/filebrowser/filebrowser) link to the Upload-Assistant directory, this will link to your filebrowser instead of uploading to uguu.se - # "filebrowser" : "https://domain.tld/filebrowser/files/Upload-Assistant/" + "TL": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # Set to False if you don't have access to the API (e.g., if you're a trial uploader). Note: this may not work sometimes due to Cloudflare restrictions. + # If you are not going to use the API, you will need to export cookies from https://www.torrentleech.org/ using https://addons.mozilla.org/en-US/firefox/addon/export-cookies-txt/. + # cookies need to be in netscape format and need to be in data/cookies/TL.txt + "api_upload": True, + # You can find your passkey at your profile (https://www.torrentleech.org/profile/[YourUserName]/view) -> Torrent Passkey + "passkey": "", + }, + "TTG": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "username": "", + "password": "", + "login_question": "", + "login_answer": "", + "user_id": "", + "announce_url": "https://totheglory.im/announce/", + "anon": False, + }, + "TVC": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "announce_url": "https://tvchaosuk.com/announce/", + "anon": False, + }, + "UHD": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "ULCX": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + # "useAPI": False, Set to True if using this tracker for automatic ID searching or description parsing + "useAPI": False, + "api_key": "", + "anon": False, + # Send to modq for staff approval + "modq": False, + }, + "UTP": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "YOINK": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "YUS": { + # Instead of using the tracker acronym for folder name when sym/hard linking, you can use a custom name + "link_dir_name": "", + "api_key": "", + "anon": False, + }, + "MANUAL": { + # Replace link with filebrowser (https://github.com/filebrowser/filebrowser) link to the Upload-Assistant directory, this will link to your filebrowser instead of uploading to uguu.se + "filebrowser": "", }, }, - - "TORRENT_CLIENTS" : { - # Name your torrent clients here, for example, this example is named "Client1" - "Client1" : { - "torrent_client" : "qbit", - "qbit_url" : "http://127.0.0.1", - "qbit_port" : "8080", - "qbit_user" : "username", - "qbit_pass" : "password", - - # Remote path mapping (docker/etc.) CASE SENSITIVE - # "local_path" : "/LocalPath", - # "remote_path" : "/RemotePath" - }, - "qbit_sample" : { - "torrent_client" : "qbit", - "enable_search" : True, - "qbit_url" : "http://127.0.0.1", - "qbit_port" : "8080", - "qbit_user" : "username", - "qbit_pass" : "password", - # "torrent_storage_dir" : "path/to/BT_backup folder" - # "qbit_tag" : "tag", - # "qbit_cat" : "category" - - # Content Layout for adding .torrents: "Original"(recommended)/"Subfolder"/"NoSubfolder" - "content_layout" : "Original" - - # Enable automatic torrent management if listed path(s) are present in the path - # If using remote path mapping, use remote path - # For using multiple paths, use a list ["path1", "path2"] - # "automatic_management_paths" : "" - - - + # enable_search to True will automatically try and find a suitable hash to save having to rehash when creating torrents + # If you find issue, especially in local/remote path mapping, use the "--debug" argument to print out some related details + "TORRENT_CLIENTS": { + # Name your torrent clients here, for example, this example is named "qbittorrent" and is set as default_torrent_client above + # All options relate to the webui, make sure you have the webui secured if it has WAN access + # **DO NOT** modify torrent_client name, eg: "qbit" + # See https://github.com/Audionut/Upload-Assistant/wiki + "qbittorrent": { + "torrent_client": "qbit", + # qui reverse proxy url, see https://github.com/autobrr/qui#reverse-proxy-for-external-applications + # If using the qui reverse proxy, no other auth type needs to be set + "qui_proxy_url": "", + # enable_search to True will automatically try and find a suitable hash to save having to rehash when creating torrents + "enable_search": True, + "qbit_url": "http://127.0.0.1", + "qbit_port": "8080", + "qbit_user": "", + "qbit_pass": "", + # Use the UA tracker acronym as a tag in qBitTorrent + "use_tracker_as_tag": False, + "qbit_tag": "", + "qbit_cat": "", + "content_layout": "Original", + # here you can chose to use either symbolic or hard links, or None to use original path + # this will disable any automatic torrent management if set + # use either "symlink" or "hardlink" + # on windows, symlinks needs admin privs, both link types need ntfs/refs filesytem (and same drive) + "linking": "", + # Allow fallback to inject torrent into qBitTorrent using the original path + # when linking error. eg: unsupported file system. + "allow_fallback": True, + # A folder or list of folders that will contain the linked content + # if using hardlinking, the linked folder must be on the same drive/volume as the original content, + # with UA mapping the correct location if multiple paths are specified. + # Use local paths, remote path mapping will be handled. + # only single \ on windows, path will be handled by UA + "linked_folder": [""], # Remote path mapping (docker/etc.) CASE SENSITIVE - # "local_path" : "E:\\downloads\\tv", - # "remote_path" : "/remote/downloads/tv" + "local_path": [""], + "remote_path": [""], + # only set qBitTorrent torrent_storage_dir if API searching does not work + # use double-backslash on windows eg: "C:\\client\\backup" + # "torrent_storage_dir": "path/to/BT_backup folder", # Set to False to skip verify certificate for HTTPS connections; for instance, if the connection is using a self-signed certificate. - # "VERIFY_WEBUI_CERTIFICATE" : True - }, - - "rtorrent_sample" : { - "torrent_client" : "rtorrent", - "rtorrent_url" : "https://user:password@server.host.tld:443/username/rutorrent/plugins/httprpc/action.php", - # "torrent_storage_dir" : "path/to/session folder", - # "rtorrent_label" : "Add this label to all uploads" - + # "VERIFY_WEBUI_CERTIFICATE": True, + }, + "rtorrent": { + "torrent_client": "rtorrent", + "rtorrent_url": "https://user:password@server.host.tld:443/username/rutorrent/plugins/httprpc/action.php", + # path/to/session folder + "torrent_storage_dir": "", + "rtorrent_label": "", + # here you can chose to use either symbolic or hard links, or leave uncommented to use original path + # use either "symlink" or "hardlink" + "linking": "", + "linked_folder": [""], # Remote path mapping (docker/etc.) CASE SENSITIVE - # "local_path" : "/LocalPath", - # "remote_path" : "/RemotePath" - - }, - "deluge_sample" : { - "torrent_client" : "deluge", - "deluge_url" : "localhost", - "deluge_port" : "8080", - "deluge_user" : "username", - "deluge_pass" : "password", - # "torrent_storage_dir" : "path/to/session folder", - + "local_path": [""], + "remote_path": [""], + }, + "deluge": { + "torrent_client": "deluge", + "deluge_url": "localhost", + "deluge_port": "8080", + "deluge_user": "username", + "deluge_pass": "password", + # path/to/session folder + "torrent_storage_dir": "", # Remote path mapping (docker/etc.) CASE SENSITIVE - # "local_path" : "/LocalPath", - # "remote_path" : "/RemotePath" + "local_path": [""], + "remote_path": [""], + }, + "transmission": { + "torrent_client": "transmission", + # http or https + "transmission_protocol": "http", + "transmission_username": "username", + "transmission_password": "password", + "transmission_host": "localhost", + "transmission_port": 9091, + "transmission_path": "/transmission/rpc", + # path/to/config/torrents folder + "torrent_storage_dir": "", + "transmission_label": "", + # Remote path mapping (docker/etc.) CASE SENSITIVE + "local_path": [""], + "remote_path": [""], }, - "watch_sample" : { - "torrent_client" : "watch", - "watch_folder" : "/Path/To/Watch/Folder" + "watch": { + "torrent_client": "watch", + # /Path/To/Watch/Folder + "watch_folder": "", }, - }, - - - - - - - - "DISCORD" :{ - "discord_bot_token" : "discord bot token", - "discord_bot_description" : "L4G's Upload Assistant", - "command_prefix" : "!", - "discord_channel_id" : "discord channel id for use", - "admin_id" : "your discord user id", - - "search_dir" : "Path/to/downloads/folder/ this is used for search", - # Alternatively, search multiple folders: - # "search_dir" : [ - # "/downloads/dir1", - # "/data/dir2", - # ] - "discord_emojis" : { - "BLU": "💙", - "BHD": "🎉", - "AITHER": "🛫", - "STC": "📺", - "ACM": "🍙", - "MANUAL" : "📩", - "UPLOAD" : "✅", - "CANCEL" : "🚫" - } - } + "DISCORD": { + # Set to True to enable Discord bot functionality + "use_discord": False, + # Set to True to only run the bot in unattended mode + "only_unattended": True, + # Set to True to send the tracker torrent urls + "send_upload_links": True, + "discord_bot_token": "", + "discord_channel_id": "", + "discord_bot_description": "", + "command_prefix": "!" + }, } - diff --git a/data/tags.json b/data/tags.json index 633d65916..ab8720ff4 100644 --- a/data/tags.json +++ b/data/tags.json @@ -1,6 +1,7 @@ { "SubsPlease" : { - "type" : "WEBDL" + "type" : "WEBDL", + "source": "WEB" }, "Pasta" : { "type" : "WEBRIP" diff --git a/data/templates/config.py b/data/templates/config.py new file mode 100644 index 000000000..02a58f32d --- /dev/null +++ b/data/templates/config.py @@ -0,0 +1,245 @@ +config = { + "DEFAULT": { + + # WAVES HAND......... + # This is not the config file you are looking for + + # This is the config file for github action testing only + + # will print a notice if an update is available + "update_notification": True, + # will print the changelog if an update is available + "verbose_notification": False, + + # tmdb api key **REQUIRED** + # visit "https://www.themoviedb.org/settings/api" copy api key and insert below + "tmdb_api": "${API_KEY}", + + # tvdb api key + # visit "https://www.thetvdb.com/dashboard/account/apikey" copy api key and insert below + "tvdb_api": "", + + # visit "https://thetvdb.github.io/v4-api/#/Login/post_login" enter api key, generate token and insert token below + # the pin in the login form is not needed (don't modify), only enter your api key + "tvdb_token": "", + + # btn api key used to get details from btn + "btn_api": "", + + # image host api keys + "imgbb_api": "", + "ptpimg_api": "", + "lensdump_api": "", + "ptscreens_api": "", + "oeimg_api": "", + "dalexni_api": "", + "passtheima_ge_api": "", + + # custom zipline url + "zipline_url": "", + "zipline_api_key": "", + + # Order of image hosts. primary host as first with others as backup + # Available image hosts: imgbb, ptpimg, imgbox, pixhost, lensdump, ptscreens, oeimg, dalexni, zipline, passtheimage + "img_host_1": "imgbb", + "img_host_2": "imgbox", + + # Whether to add a logo for the show/movie from TMDB to the top of the description + "add_logo": True, + + # Logo image size + "logo_size": "300", + + # logo language (ISO 639-1) + # If a logo with this language cannot be found, en (English) will be used instead + "logo_language": "", + + # Number of screenshots to capture + "screens": "4", + + # Number of screenshots per row in the description. Default is single row. + # Only for sites that use common description for now + "screens_per_row": "", + + # Overlay Frame number/type and "Tonemapped" if applicable to screenshots + "frame_overlay": True, + + # Overlay text size (scales with resolution) + "overlay_text_size": "18", + + # Tonemap HDR - DV+HDR screenshots + "tone_map": True, + + # Tonemap HDR screenshots with the following settings + # See https://ayosec.github.io/ffmpeg-filters-docs/7.1/Filters/Video/tonemap.html + "algorithm": "mobius", + "desat": "10.0", + + # Add this header above screenshots in description when screens have been tonemapped (in bbcode) + "tonemapped_header": "", + + # Number of cutoff screenshots + # If there are at least this many screenshots already, perhaps pulled from existing + # description, skip creating and uploading any further screenshots. + "cutoff_screens": "4", + + # MULTI PROCESSING + # The optimization task is resource intensive. + # The final value used will be the lowest value of either 'number of screens' + # or this value. Recommended value is enough to cover your normal number of screens. + # If you're on a shared seedbox you may want to limit this to avoid hogging resources. + "process_limit": "4", + + # When optimizing images, limit to this many threads spawned by each process above. + # Recommended value is the number of logical processesors on your system. + # This is equivalent to the old shared_seedbox setting, however the existing process + # only used a single process. You probably need to limit this to 1 or 2 to avoid hogging resources. + "threads": "4", + + # Providing the option to change the size of the screenshot thumbnails where supported. + # Default is 350, ie [img=350] + "thumbnail_size": "350", + + # Number of screenshots to use for each (ALL) disc/episode when uploading packs to supported sites. + # 0 equals old behavior where only the original description and images are added. + # This setting also affects PTP, however PTP requries at least 2 images for each. + # PTP will always use a *minimum* of 2, regardless of what is set here. + "multiScreens": "2", + + # The next options for packed content do not effect PTP. PTP has a set standard. + # When uploading packs, you can specifiy a different screenshot thumbnail size, default 300. + "pack_thumb_size": "300", + + # Description character count (including bbcode) cutoff for UNIT3D sites when **season packs only**. + # After hitting this limit, only filenames and screenshots will be used for any ADDITIONAL files + # still to be added to the description. You can set this small like 50, to only ever + # print filenames and screenshots for each file, no mediainfo will be printed. + # UNIT3D sites have a hard character limit for descriptions. A little over 17000 + # worked fine in a forum post at AITHER. If the description is at 1 < charLimit, the next full + # description will be added before respecting this cutoff. + "charLimit": "14000", + + # How many files in a season pack will be added to the description before using an additional spoiler tag. + # Any other files past this limit will be hidden/added all within a spoiler tag. + "fileLimit": "2", + + # Absolute limit on processed files in packs. + # You might not want to process screens/mediainfo for 40 episodes in a season pack. + "processLimit": "10", + + # Providing the option to add a description header, in bbcode, at the top of the description section + # where supported + "custom_description_header": "", + + # Providing the option to add a header, in bbcode, above the screenshot section where supported + "screenshot_header": "", + + # Enable lossless PNG Compression (True/False) + "optimize_images": True, + + # Which client are you using. + "default_torrent_client": "qbittorrent", + + # Play the bell sound effect when asking for confirmation + "sfx_on_prompt": True, + + # How many trackers need to pass successfull checking to continue with the upload process + # Default = 1. If 1 (or more) tracker/s pass banned_group, content and dupe checking, uploading will continue + # If less than the number of trackers pass the checking, exit immediately. + "tracker_pass_checks": "1", + + # Set to true to always just use the largest playlist on a blu-ray, without selection prompt. + "use_largest_playlist": False, + + # set true to only grab meta id's from trackers, not descriptions and images + "only_id": False, + + # set true to use mkbrr for torrent creation + "mkbrr": True, + + # set true to use argument overrides from data/templates/user-args.json + "user_overrides": False, + + # set true to add episode overview to description + "episode_overview": True, + + # set true to skip automated client torrent searching + # this will search qbittorrent clients for matching torrents + # and use found torrent id's for existing hash and site searching + 'skip_auto_torrent': True, + + # NOT RECOMMENDED UNLESS YOU KNOW WHAT YOU ARE DOING + # set true to not delete existing meta.json file before running + "keep_meta": False, + + # If there is no region/distributor ids specified, we can use existing torrents to check + # This will use data from matching torrents in qBitTorrent/RuTorrent to find matching site ids + # and then try and find region/distributor ids from those sites + # Requires "skip_auto_torrent" to be set to False + "ping_unit3d": False, + + # If processing a bluray disc, get bluray information from bluray.com + # This will set region and distribution info + # Must have imdb id to work + "get_bluray_info": False, + + # Add bluray.com link to description + # Requires "get_bluray_info" to be set to True + "add_bluray_link": False, + + # Add cover/back/slip images from bluray.com to description if available + # Requires "get_bluray_info" to be set to True + "use_bluray_images": False, + + # Size of bluray.com cover images. + # bbcode is width limited, cover images are mostly hight dominant + # So you probably want a smaller size than screenshots for instance + "bluray_image_size": "250", + + # A release with 100% score will have complete matching details between bluray.com and bdinfo + # Each missing Audio OR Subtitle track will reduce the score by 5 + # Partial matched audio tracks have a 2.5 score penalty + # If only a single bdinfo audio/subtitle track, penalties are doubled + # Video codec/resolution and disc size mismatches have huge penalities + # Only useful in unattended mode. If not unattended you will be prompted to confirm release + # Final score must be greater than this value to be considered a match + "bluray_score": 94.5, + + # If there is only a single release on bluray.com, you may wish to relax the score a little + "bluray_single_score": 89.5, + + }, + + # these are used for DB links on AR + "IMAGES": { + "imdb_75": 'https://i.imgur.com/Mux5ObG.png', + "tmdb_75": 'https://i.imgur.com/r3QzUbk.png', + "tvdb_75": 'https://i.imgur.com/UWtUme4.png', + "tvmaze_75": 'https://i.imgur.com/ZHEF5nE.png', + "mal_75": 'https://i.imgur.com/PBfdP3M.png' + }, + + "TRACKERS": { + "default_trackers": "TL", + "TL": { + "announce_key": "test", + }, + }, + + # enable_search to true will automatically try and find a suitable hash to save having to rehash when creating torrents + # Should use the qbit API, but will also use the torrent_storage_dir to find suitable hashes + # If you find issue, use the "--debug" command option to print out some related details + "TORRENT_CLIENTS": { + # Name your torrent clients here, for example, this example is named "Client1" and is set as default_torrent_client above + # All options relate to the webui, make sure you have the webui secured if it has WAN access + # See https://github.com/Audionut/Upload-Assistant/wiki + "qbittorrent": { + "torrent_client": "qbit", + "enable_search": False, + "qbit_url": "http://127.0.0.1", + "qbit_port": "8080", + "qbit_user": "", + "qbit_pass": "", + } + } +} diff --git a/data/templates/summary-mediainfo.csv b/data/templates/summary-mediainfo.csv new file mode 100644 index 000000000..1db5c3f11 --- /dev/null +++ b/data/templates/summary-mediainfo.csv @@ -0,0 +1,7 @@ +General;FILENAME.......: %FileName%\nCONTAINER......: %Format%\nSIZE...........: %FileSize_String3%\nRUNTIME........: %Duration_String2%\n +Video;VIDEO CODEC....: %Format%$if(%Encoded_Library%,\, %Encoded_Library%)$if(%HDR_Format/String%,\, %HDR_Format/String%)$if(%transfer_characteristics%,\, %transfer_characteristics%)\nRESOLUTION.....: %Width%x%Height%\nBITRATE........: %BitRate/String%\nFRAMERATE......: %FrameRate% fps\n +Audio;AUDIO..........: %Format_Commercial%(%Format%), %Channel(s)/String%, %SamplingRate/String%, %BitRate/String%, %Language/String%$if(%Title%, \(%Title%\),)\n +Text;%Language_String% +Text_Begin;SUBTITLES......: +Text_Middle;, +Text_End; diff --git a/data/templates/user-args-template.json b/data/templates/user-args-template.json new file mode 100644 index 000000000..4168280d8 --- /dev/null +++ b/data/templates/user-args-template.json @@ -0,0 +1,47 @@ +## This is just a template file. Replace with your own values and rename to user-args.json +## 'entries' are tmdb only and update args **after** metadata processing +## Changing category or season or episode for instance, is ill advised as these are used in metadata processing +## 'other_ids' are for tvdb or imdb and update args before metadata processing +## all args with 'other_ids' should be quite safe +## Not throughly tested, for args try using a list instead of a string or vice versa if you encounter issues + +{ + "entries": [ + { + "tmdb_id": "tv/12345", + "args": [ + "--no-aka", + "--screens", "4" + ] + }, + { + "tmdb_id": "movie/54321", + "args": [ + "--no-year", + "--personalrelease", + "--descfile", "path_do_desc_file" + "--manual_frames=100,200,300,400,500,600,700" + ] + } + ], + "other_ids": [ + { + "imdb_id": "12345", + "args": [ + "--no-aka", + "--not-anime", + "--category", "tv" + ] + }, + { + "tvdb_id": "45678", + "args": [ + "--no-aka", + "--not-anime", + "--category", "tv", + "--tmdb", "12345", + "--tvmaze", "12345" + ] + } + ] + } \ No newline at end of file diff --git a/data/version.py b/data/version.py new file mode 100644 index 000000000..ede1f09b0 --- /dev/null +++ b/data/version.py @@ -0,0 +1,1790 @@ +__version__ = "v6.0.1" + +""" +Release Notes for version v6.0.1 (2025-10-04): + +# ## What's Changed +# +# * fix version file by @Audionut in c8ccf5a +# * erroneous v in version file by @Audionut in 5428927 +# * Fix YUS get_type_id (#850) by @oxidize9779 in 25591e0 +# * fix: LCD and UNIT3D upload (#852) by @wastaken7 in f5d11b8 +# * Update banned release groups of various trackers (#848) by @flowerey in 9311996 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v6.0.0...v6.0.1 +""" + + +""" +Changelog for version v6.0.0 (2025-10-03): + +# ## RELEASE NOTES +# - Immense thanks to @wastaken7 for refactoring the unit3d based tracker code. A huge QOL improvement that removed thousands of lines of code. +# - To signify the continued contributions by @wastaken7, this project is now know simply as "Upload Assistant". +# - New package added, run requirements.txt +# - This release contains lengthy refactoring of many code aspects. Many users, with thanks, have been testing the changes and giving feedback. +# - The version bump to v6.0.0 signifies the large code changes, and you should follow an update process suitable for yourself with a major version bump. + +## New config options - see example.py +# - FFMPEG related options that may assist those having issues with screenshots. +# - AvistaZ based sites have new options in their site sections. +# - "use_italian_title" inside SHRI config, for using Italian titles where available +# - Some HDT related config options were updated/changed +# - "check_predb" for also checking predb for scene status +# - "get_bluray_info" updated to also include getting DVD data +# - "qui_proxy_url" inside qbittorrent client config, for supporting qui reverse proxy url + +# ## WHAT'S NEW - some from last release +# - New arg -sort, used for sorting filelist, to ensure UA can run with some anime folders that have allowed smaller files. +# - New arg -rtk, which can be used to process a run, removing specific trackers from your default trackers list, and processing with the remaining trackers in your default list. +# - A significant chunk of the actual upload process has been correctly asynced. Some specific site files still need to be updated and will slow the process. +# - More UNIT3D based trackers have been updated with request searching support. +# - Added support for sending applicable edition to LST api edition endpoint. +# - NoGrp type tags are not removed by default. Use "--no-tag" if desired, and/or report trackers as needed. + +# ## What's Changed +# +# * fix(GPW) - do not print empty descriptions (#805) by @wastaken7 in 07e8334 +# * SHRI - Check group tag and Italian title handling (#803) by @Tiberio in 054ce4f +# * fix(HDS) - use [pre] for mediainfo to correctly use monospaced fonts (#810) by @wastaken7 in aa62941 +# * fix(BT) - status code, post data, torrent id (#808) by @wastaken7 in 5ff6249 +# * feat(UNIT3D) - refactor UNIT3D websites to reuse common code base (#801) by @wastaken7 in 03c8ffd +# * ANT - fix trying to call lower on dict by @Audionut in 9772b0a +# * SHRI - Remove 'Dubbed', add [SUBS] tag (#815) by @Tiberio in 788be1c +# * graceful exit by @Audionut in ddbd135 +# * updated unit3d trackers - request support by @Audionut in a680692 +# * release notes by @Audionut in 49efdca +# * Update FNP resolution id (#818) by @oxidize9779 in 48fa975 +# * refactor(HDT) (#821) by @wastaken7 in 2365937 +# * more async (#819) by @Audionut in b7aea98 +# * print in debug by @Audionut in 9b68819 +# * set screens from manual frames by @Audionut in b9ef753 +# * more debugging by @Audionut in 5ad4fce +# * more debugging by @Audionut in 7902066 +# * Refine dual-audio detection for zxx (#822) by @GizmoBal in ab27990 +# * fix extended bluray parsing by @Audionut in cae1c38 +# * feat: Improve duplicate search functionality (#820) by @wastaken7 in 3b59c03 +# * remove dupe requirement by @Audionut in 5ebdc86 +# * disable filename match by @Audionut in 63adf3c +# * fix unit3d flags by @Audionut in 3555d12 +# * exact filename fix by @Audionut in 69aa3fa +# * Improve NFO downloading robustness (#827) by @noobiangodd in 09bc878 +# * PTP redact token by @Audionut in eec5d60 +# * enable predb by @Audionut in a073247 +# * qbit retries and async calls by @Audionut in 9146011 +# * add sleeps to pack processing by @Audionut in ed7eda9 +# * add DOCPLAY by @Audionut in aa97763 +# * fix unit3d flags api by @Audionut in 506ea47 +# * LST edition ids by @Audionut in df7769a +# * more parsers to lxml by @Audionut in e62e819 +# * fix pack image creation by @Audionut in 220c5f2 +# * fix request type checking by @Audionut in a06c1dd +# * Fix crash when no edit args provided (handle no/empty input safely) (#826) by @ca1m985 in 4cbebc4 +# * catch keyboard interruptions in cli_ui by @Audionut in ca76801 +# * don't remove nogrp type tags by default by @Audionut in 25b5f09 +# * AZ network fixes by @Audionut in 50595c2 +# * fix: only print overlay info if relevant by @Audionut in 4e6a5ce +# * add(meta): video container (#831) by @wastaken7 in c55094a +# * fix: frame overlay check tracker list check by @Audionut in 6d7fa3c +# * fix use_libplacebo false by @Audionut in d1044c9 +# * fix: improve container detection for different disc types (#835) by @wastaken7 in 073126c +# * set safe debugging languages by @Audionut in bfe964a +# * print automated ffmpeg tonemap checking failure by @Audionut in bebe17c +# * fix: don't overwrite ids from mediainfo by @Audionut in f3fa16c +# * HDT - auth token availability (#839) by @Audionut in 57af870 +# * Add support for bluray.com scraping for DVDs (#828) by @9Oc in 6274db1 +# * Update config-generator.py (#846) by @AzureBelmont in 070062c +# * fix(ANT): add type and audioformat to post data (#845) by @wastaken7 in 3424794 +# * refactor: replace UploadException with tracker_status handling, where applicable (#840) by @wastaken7 in 502e40d +# * cleanup handling for android by @Audionut in 1702d3d +# * add support for qui reverse proxy (#833) by @Audionut in 9a9b3c4 +# * improvement: avoid re-executing validate_credentials by temporarily saving tokens in meta (#834) by @wastaken7 in ff99d08 +# * release notes by @Audionut in a924df4 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.4.3...v6.0.0 +""" + +__version__ = "5.4.3" + +""" +Release Notes for version v5.4.3 (2025-09-19): + +# ## What's Changed +# +# * category regex tweak by @Audionut in f7c02d1 +# * Fix HUNO UHD remux (#767) by @oxidize9779 in 1bb0ae8 +# * Update to banned groups ULCX.py (#770) by @Zips-sipZ in dd0fdd9 +# * fix(HDT): update base URL (#766) by @wastaken7 in bb16dc3 +# * fix(BJS): Remove Ultrawide tag detection from remaster tags (#768) by @wastaken7 in 99e1788 +# * Added support for AvistaZ (#769) by @wastaken7 in 5bdf3cd +# * TL - api upload update by @Audionut in 341248a +# * add tonemapping header to more sites by @Audionut in 307ba71 +# * fix existing tonemapped status by @Audionut in 4950b08 +# * HDB - fix additional space in name when atmos by @Audionut in 8733c65 +# * fix bad space by @Audionut in 9165411 +# * set df encoding by @Audionut in 323a365 +# * TL api tweaks by @Audionut in 9fbde8f +# * TL - fix search existing option when api by @Audionut in 534ece7 +# * TL - add debugging by @Audionut in ab37785 +# * fix bad copy/paste by @Audionut in 6d25afd +# * TL - login update by @Audionut in 677cee8 +# * git username mapping by @Audionut in 60ed690 +# * FNP - remove a group for banned release groups (#775) by @flowerey in ab4f79a +# * Added support for CinemaZ, refactor Z sites to reuse common codebase (#777) by @wastaken7 in f14066f +# * Update titles of remux for HDB (#778) by @GizmoBal in b9473cb +# * Added support for GreatPosterWall (#779) by @wastaken7 in 4dc1b65 +# * SHRI - language handling in name by @Audionut in 5ee449f +# * fix(GPW) - timeout, screenshots, check available slots (#789) by @wastaken7 in 5862df4 +# * fix(AvistaZ sites) - languages, resolution, naming, rules (#782) by @wastaken7 in 10bf73f +# * add argument trackers remove by @Audionut in 1b0c549 +# * add(region.py) - Kocowa+ (#790) by @wastaken7 in da0b39a +# * fix(CBR.py) - UnboundLocalError when uploading a full disc (#791) by @wastaken7 in dbe3964 +# * Fix HUNO bit rate detection (#792) by @oxidize9779 in da1b891 +# * SHRI - remove dual audio by @Audionut in 5f94385 +# * add argument -sort (#796) by @Audionut in 0d0f1a4 +# * add config options for ffmpeg (#798) by @Audionut in 0dc4275 +# * add venv to .gitignore (#797) by @Tiberio in 5edfbeb +# * strip multiple spaces from bdinfo (#786) by @Audionut in 38a09aa +# * fix SHRI dual audio brain fart by @Audionut in 8623b18 +# * BHD - request search support (#773) by @Audionut in f0f5685 +# * can't spell by @Audionut in 159fc0f +# * update DP ban list (#800) by @emb3r in 42dd363 +# * fix(Avistaz) - add XviD/DivX to meta (#793) by @wastaken7 in a797844 +# * Remove TOCASHARE from supported sites (#802) by @wastaken7 in cf25142 +# * conform to GPW description image rules (#804) by @GuillaumedeVolpiano in 24c625e +# * add(get_name.py) - year for DVD's, audio for DVDRip's (#799) by @wastaken7 in adfb263 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.4.2...v5.4.3 +""" + + +""" +Release Notes for version v5.4.2 (2025-09-03): + +# ## What's Changed +# +# * enhance(PHD): add search requests option, tags and other changes (#749) by @wastaken7 in 1c970ce +# * enhance(BT): use tmdb cache file and other changes (#750) by @wastaken7 in a793060 +# * enhance(HDS): add search requests option and other changes (#751) by @wastaken7 in b0f88e3 +# * python does python things by @Audionut in 057d2be +# * FNP - fix banned groups (#753) by @flower in 54c5c32 +# * more python quoting fixes by @Audionut in d8a6779 +# * MOAR quotes by @Audionut in 7a62585 +# * chore: fix incompatible f-strings with python 3.9 (#754) by @wastaken7 in 9a8f190 +# * fix(HUNO) - add multi audio, UHD BluRay naming (#756) by @wastaken7 in 5b41f4d +# * fix default tracker list through edit process by @Audionut in 354e9c1 +# * move sanatize meta definition by @Audionut in 9d2991b +# * catch mkbrr config error by @Audionut in 34e05f9 +# * Added HDT (HD-Torrents) to client.py to allow tracker removal (#760) by @FortKnox1337 in 6c5bbc5 +# * fix(PHD): add BD resolution, basic description, remove aka from title (#761) by @wastaken7 in 8459a45 +# * fix(DC): Resize images in description generation (#762) by @wastaken7 in 41d7173 +# * add(client.py): skip more trackers (#763) by @wastaken7 in 61dfd4a +# * HUNO - unit3d torrent download by @Audionut in 637a145 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.4.1...v5.4.2 +""" + + +""" +Release Notes for version v5.4.1 (2025-09-02): + +# ## What's Changed +# +# * fix missing trackers for language processing (#747) by @wastaken7 in 34d0b4b +# * add missing function to common by @Audionut in 33d5aec +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.4.0...v5.4.1 +""" + + +""" +Release Notes for version v5.4.0 (2025-09-02): + +# +# ## RELEASE NOTES +# - Blutopia has a peer scraping issue that resulted in UNIT3D codebase being updated, requiring torrent files to be created site side. See https://github.com/HDInnovations/UNIT3D/pull/4910 +# - With the infohash being randomized site side, UA can no longer create valid torrent files for client injection, and instead the torrent file needs to be downloaded for client injection. +# - All UNIT3D based sites have been updated to prevent any issues moving forward as other sites update their UNIT3D codebase. +# - This will cause small slowdown in the upload process, as each torrent file is downloaded from corresponding sites. +# - Announce URLS for the supported sites are no longer needed in config, check example-config.py for the removed announce urls. +# +# ## WHAT'S NEW +# - UA can now search for related requests for the uploaded content, allowing you to quickly and easily see which requests can be filled by your upload. +# - Request checking via config option (see example-config) or new arg (see --help) +# - Only ASC, BJS and ULCX supported currently +# - Added a new arg to skip auto torrent searching +# +# --- +# +# ## What's Changed +# +# * Added support for PTSKIT (#730) by @wastaken7 in 19ccbe5 +# * add missing site details (#731) by @wastaken7 in e96cd15 +# * LCD - fix region, mediainfo, naming (#732) by @wastaken7 in de38dba +# * SPD - fix and changes (#727) by @wastaken7 in 16d310c +# * BLU - update torrent injection (#736) by @wastaken7 in a2d14af +# * Fix BHD tracker matching (#740) by @backstab5983 in 80b4337 +# * fix(SPD): send description to BBCode-compatible field (#738) by @wastaken7 in 95e5ab7 +# * Update HDB.py to clean size bbcode (#734) by @9Oc in 8d15765 +# * Update existing client-tracker search to add 3 more trackers (#728) by @FortKnox1337 in 3dcbb7c +# * correct screens track mapping and timeout by @Audionut in c9d5466 +# * skip auto torrent as arg by @Audionut in b78bb0a +# * fix queue handling when all trackers already in client by @Audionut in aae803f +# * skip pathed torrents when edit mode by @Audionut in eafb38c +# * preserve sat true by @Audionut in ffaddd4 +# * ULCX - remove hybrid from name by @Audionut in 1f02274 +# * fix existing torrent search when not storage directory and not qbit by @Audionut in 85e653f +# * DP - no group tagging by @Audionut in f4e236d +# * HDB - music category by @Audionut in 6a12335 +# * Option - search tracker requests (#718) by @Audionut in 2afce5b +# * add tracker list debug by @Audionut in 5418f05 +# * enhance(ASC): add localized TMDB data and search requests option (#743) by @wastaken7 in e2a3963 +# * refactor unit3d torrent handling (#741) by @Audionut in 56b3b14 +# * enhance(DC): httpx, MediaInfo for BDs, and upload split (#744) by @wastaken7 in de98c6e +# * PT- ensure audio_pt and legenda_pt flags only apply to European Portuguese (#725) by @Thiago in f238fc9 +# * fix TAoE banned group checking by @Audionut in 1e8633c +# * enhance(BJS): add localized TMDB data and search requests option (#746) by @wastaken7 in e862496 +# * redact passkeys from debug prints by @Audionut in 89809bb +# * clarify request usage by @Audionut in 5afafc0 +# * BJS also does request searching by @Audionut in d87f060 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.3.6...v5.4.0 +""" + + +""" +Release Notes for version v5.3.6 (2025-08-22): + +# ## What's Changed +# +# * fix docker mkbrr version by @Audionut in 69a1384 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.3.5...v5.3.6 +""" + + +""" +Release Notes for version v5.3.5 (2025-08-22): + +# ## What's Changed +# +# * TL - cleanup torrent file handling (#714) by @wastaken7 in 011d588 +# * ANT tag reminder by @Audionut in fbb8c2f +# * Added support for FunFile (#717) by @wastaken7 in 6436d34 +# * ULCX - aka check by @Audionut in 3b30132 +# * ANT - manual commentary flag (#720) by @wastaken7 in d8fd725 +# * [FnP] Fix resolutions, types and add banned release groups (#721) by @flower in 5e38b0e +# * Revert "Dockerfile Improvements (#710)" by @Audionut in c85e83d +# * fix release script by @Audionut in d86999d +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.3.4...v5.3.5 +""" + + +""" +Release Notes for version v5.3.4 (2025-08-18): + +# +# ## RELEASE NOTES +# - UA can now tonemap Dolby Vision profile 5 and HLG files. +# - Requires a compatible ffmpeg (get latest), see https://github.com/Audionut/Upload-Assistant/pull/706 +# - Adjust the related ffmpeg option in config, if you have a suitable ffmpeg installed, in order to skip the automated check +# +# --- +# +# ## What's Changed +# +# * RF - now needs 2fa enabled to upload by @Audionut in e731e27 +# * TL - fix outdated attribute (#701) by @wastaken7 in ebabb5d +# * Fix typo in source flag when uploading to SHRI (#703) by @backstab5983 in 0e5bb28 +# * Catch conformance error from mediainfo and warn users (#704) by @Khoa Pham in febe0f1 +# * Add correct country get to IMDb (#708) by @Audionut in e09dbf2 +# * catch empty array from btn by @Audionut in 77b539a +# * highlight tracker removal by @Audionut in 95a9e54 +# * Fix img_host and None types (#707) by @frenchcutgreenbean in c34e6be +# * Option - libplacebo tonemapping (#706) by @Audionut in 3fc3c1a +# * fix docker tagging by @Audionut in 0071c71 +# * clean empty bbcode from descriptions by @Audionut in 73b40b9 +# * require api key to search by @Audionut in ce7bec6 +# * Dockerfile Improvements (#710) by @Slikkster in 0b50d36 +# * restore docker apt update by @Audionut in a57e514 +# * PHD - fix region logic (#709) by @wastaken7 in 5e1c541 +# * fix unit3d trackers not accept valid tvdb by @Audionut in 309c54e +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.3.3...v5.3.4 +""" + + +""" +Release Notes for version v5.3.3 (2025-08-14): + +# +# ## RELEASE NOTES +# - New module added requiring update via requirements.txt. See README for instructions. +# +# --- +# +# ## What's Changed +# +# * use all of result when specific is NoneType by @Audionut in 15faaad +# * don't print guessit error in imdb by @Audionut in 3b21998 +# * add support for multiple announce links (#691) by @wastaken7 in 4a623d7 +# * Added support for PHD (#689) by @wastaken7 in 1170f46 +# * pass meta to romaji by @Audionut in 6594f2c +# * DC - API update (#695) by @wastaken7 in 14380f2 +# * remove trackers found in client (#683) by @Audionut in 3207fd3 +# * Add service Chorki (#690) by @razinares in fa16ebf +# * fix docker mediainfo install (#699) by @Audionut in aa84c07 +# * Option - send upload urls to discord (#694) by @Audionut in 29fbcf5 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.3.2...v5.3.3 +""" + + +""" +Release Notes for version v5.3.2 (2025-08-11): + +# ## What's Changed +# +# * AR - catch multiple dots in name by @Audionut in 5d5164b +# * correct meta object before inputting data by @Audionut in 166a1a5 +# * guessit fallback by @Audionut in eccef19 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.3.1...v5.3.2 +""" + + +""" +Release Notes for version v5.3.1 (2025-08-10): + +# ## What's Changed +# +# * TVDB series name not nonetype by @Audionut in 1def355 +# * remove compatibility tracks from dupe/dubbed checking by @Audionut in 48e922e +# * fix onlyID (#677) by @Audionut in 29b8caf +# * BT & BJS - fix language, add user input (#678) by @wastaken7 in 51d89c5 +# * fix: update SP category logic (#679) by @groggy9788 in 9ed3b2d +# * update mkbrr and add threading control (#680) by @Audionut in 316afe1 +# * add tv support for emby (#681) by @Audionut in 0de649b +# * add service XUMO by @Audionut in 633f151 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.3.0...v5.3.1 +""" + + +""" +Release Notes for version v5.3.0 (2025-08-08): + +# +# ## NOTES +# - From the previous release, screenshots in description were modified. Check the options in the example-config to handle to taste, particularly https://github.com/Audionut/Upload-Assistant/blob/f45e4dd87472ab31b79569f97e3bea62e27940e0/data/example-config.py#L70 +# +# +# ## RELEASE NOTES +# - UA will no longer, 'just pick the top result suggested by TMDb'. +# - Instead, title parsing has been significantly improved. Now UA will use a weight based system that relies on the title name, AKA name and year . +# - Old scene releases such as will easily defeat the title parsing, however these releases will get an IMDB ID from srrdb, negating this issue. Poorly named P2P releases are exactly that. +# - Unfortunately, not only are there many, many releases that have exactly matching names, and release years, TMDb's own sorting algorithm doesn't perfectly return the correct result, as the first result, always. +# - This means that a prompt is required. UA will display a shortened list of results for you to select, an allows manual entry of the correct TMDb ID, such as /. +# - Given that UA would have just selected the first result previously, which could have been incorrect, some percentage of time, the net result should be a better overall user experience, since the wrong return previously required manual intervention in any event, and may have been missed previously, leading to lack luster results. +# - As always, feeding the correct ID's into UA always leads to a better experience. There are many options to accomplish this task automatically, and users should familiarize themselves with the options outlined in the example.config, and/or user-args.json +# - Overall SubsPlease handling should be greatly increased......if you have TVDB login details. +# +# ## NOTEWORTHY UPDATES +# - Two new trackers, BT and BJS have been added thanks to @wastaken7 +# - PSS was removed as offline +# - The edit pathway, when correcting Information, should now correctly handle existing args thanks to @ppkhoa +# - Some additional context has been added regarding ffmpeg screen capture issues, particularly on seedboxes, also see https://github.com/Audionut/Upload-Assistant/wiki/ffmpeg---max-workers-issues +# - Additional trackers have been added for getting existing ids, but they are currently only available via auto torrent searching +# - Getting data from trackers now has a cool off period. This should not be noticed under normal circumstances. PTP has a 60 second cool off period, which was chosen to minimize interference with other tools. +# +# --- +# +# ## What's Changed +# +# * update install/update instructions by @Audionut in 6793709 +# * TMDB retry (#646) by @Audionut in 84554d8 +# * fix missing tvdb credential checks by @Audionut in 28b0561 +# * cleanup ptp description/images handling by @Audionut in 271fc5f +# * fix bad copy/paste by @Audionut in d075a11 +# * set the ptp_imagelist by @Audionut in 3905248 +# * add option to select specific new files for queue (#648) by @Audionut in 8de31e3 +# * TMDB retry, set object by @Audionut in 12436ff +# * robust framerate by @Audionut in 955be6d +# * add clarity of max workers issues on seedboxes by @Audionut in d38f265 +# * add linux ffmpeg check by @Audionut in 89bf550 +# * ffmpeg - point to wiki by @Audionut in 6d6246b +# * generic max workers error print by @Audionut in 71d00c0 +# * handle specific ffmpeg complex error by @Audionut in 6e104ea +# * frame overlay print behind debug by @Audionut in 72804de +# * Log_file - save debug logs (#653) by @Audionut in 482dce5 +# * SPD - fix imdb in search existing (#656) by @Audionut in a640da6 +# * Skip torrents for AL if they don't have a MAL ID (#651) by @PythonCoderAS in 045bb71 +# * overrides - import at top by @Audionut in bb662e2 +# * ignore mkbrr binaries by @Audionut in 37f3d1c +# * Don't discard original args, override them (#660) by @Khoa Pham in 9554f21 +# * remove PSS (#663) by @Audionut in 31a6c57 +# * ULCX - remove erroneous space in name by @Audionut in 5bb5806 +# * fix subplease service check by @Audionut in 9fa53ba +# * fix tmdb secondary title search by @Audionut in bf77018 +# * imdb - get more crew info (#665) by @wastaken7 in 208f65c +# * Added support for BJS (#649) by @wastaken7 in 61fb607 +# * BJS - add internal flag (#668) by @wastaken7 in 3cb93f5 +# * BT - refactor (#669) by @wastaken7 in d1c6d83 +# * BJS - safe string handling of description file by @Audionut in 7c1ef78 +# * BT - safe string handling of description file by @Audionut in 67b1fce +# * rTorrent debugging by @Audionut in fb31951 +# * Update release notes handling (#671) by @Audionut in f45e4dd +# * Fix manual tracker mode (#673) by @Audionut in fdf3b54 +# * BT and BJS fixes (#672) by @wastaken7 in c478149 +# * fix: python compatibility in BJS (#674) by @wastaken7 in 9535259 +# * Add arg, skip-dupe-asking (#675) by @Audionut in 7844ce6 +# * BHD - fix tracker found match by @Audionut in 4a82aed +# * TL - fix description uploading in api mode by @Audionut in d36002e +# * ffmpeg - only first video streams by @Audionut in 85fc9ca +# * Get language from track title (#676) by @Audionut in 013aed1 +# * TMDB/IMDB searching refactor and EMBY handling (#637) by @Audionut in f68625d +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.2.1...v5.3.0 +""" + + +""" +Release Notes for version v5.2.1 (2025-07-30): + +# ## What's Changed +# +# * fix no_subs meta by @Audionut in 86f2bcf +# * Robust id from mediainfo (#645) by @Audionut in 9c43584 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/v5.2.0...v5.2.1 +""" + + +""" +Release Notes for version v5.2.0 (2025-07-29): + +# ## What's Changed +# +# * hc subs language handling by @Audionut in 762eed8 +# * pack check also being cat by @Audionut in 9279b8e +# * CBR - bdmv language check by @Audionut in de461fb +# * set hc_language meta object by @Audionut in 0bd92d7 +# * LT Spanish catches by @Audionut in ac9dc35 +# * Revert LT Spanish catches by @Audionut in 51c64f2 +# * remove verbose print by @Audionut in fc3d1b8 +# * LT.py SUBS parser failed Spanish (AR) (#626) by @Hielito in 7b6292e +# * clarify image size else print by @Audionut in a13211b +# * fix tvmaze returning None ids by @Audionut in 0769997 +# * move tvdb search outside tv_pack by @Audionut in 6573337 +# * get_tracker_data.py - lower HUNO priority (#629) by @wastaken7 in 8156bc8 +# * ASC - type mapping and description fix (#628) by @wastaken7 in f0defc9 +# * debug status message by @Audionut in 26038d4 +# * OE - DS4K in name by @Audionut in 84e7517 +# * Update languages.py (#633) by @wastaken7 in ae963ab +# * Add option to use entropy by @Audionut in dbba7f0 +# * queue update by @Audionut in 9b1775d +# * don't add useless folders to queue by @Audionut in 63113d6 +# * ffmpeg only video stream by @Audionut in 049697a +# * Merge branch 'queue-update' by @Audionut +# * group check dvd by @Audionut in 7b68370 +# * Better matching of files against foldered torrents by @Audionut in 6af32a9 +# * Add linux option to use custom ffmpeg binary by @Audionut in 3baa389 +# * Give screenshots some spaces to breathe (#639) by @Khoa Pham in aba0bb6 +# * Merge branch 'ffmpeg' by @Audionut +# * ASC - strengthen the description against NoneType errors (#638) by @wastaken7 in c2cdba6 +# * CBR - handle no_dual by @Audionut in 7133915 +# * CBR also remove the dual-audio by @Audionut in f62247f +# * set dual-audio meta by @Audionut in afb8175 +# * mkbrr - only wanted binary by @Audionut in 57d9c5d +# * correct call by @Audionut in d005b37 +# * Note about ffmpeg linux binary by @Audionut in f792c56 +# * TL - add http upload option (#627) by @wastaken7 in 5d27d27 +# * Merge branch 'auto-torrent-searching' by @Audionut +# * clarify usage in arg by @Audionut in 639328e +# * Merge branch 'entropy' by @Audionut +# * Prioritize arg descriptions by @Audionut in c11c3a4 +# * fix id from mi by @Audionut in 694c331 +# * docker mkbrr binary by @Audionut in 9077df6 +# * correct filename by @Audionut in 6a6e8e8 +# * Merge branch 'mkbrr-binaries' by @Audionut +# * Correct versioning in releases (#644) by @Audionut in a279a6a +# * Improve metadata finding (#636) by @Audionut in 9e32eaa +# * correct base_dir by @Audionut in 9bb68fd +# * fix docker do not tag manual as latest by @Audionut in f373286 +# * Other minor updates and improvements +# +# **Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.5.2...v5.2.0 +""" + + +""" +Changelog for version 5.1.5.2 (2025-07-19): + +## What's Changed +* Update README to include supported trackers list by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/619 +* Get correct discord config in upload.py by @ppkhoa in https://github.com/Audionut/Upload-Assistant/pull/621 +* DC - Remove file extensions from upload filename before torrent upload by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/622 +* Fixed a DC edition check +* Fixed a tracker status check + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.5.1...5.1.5.2 +""" + +__version__ = "5.1.5.1" + +""" +Changelog for version 5.1.5.1 (2025-07-19): + +- Language bases fixes. + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.5...5.1.5.1 +""" + +__version__ = "5.1.5" + +""" +Changelog for version 5.1.5 (2025-07-18): + +## What's Changed +* Fix LT edit name by @Hielito2 in https://github.com/Audionut/Upload-Assistant/pull/595 +* HUNO encode checks by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/600 +* Update ULCX Banned Release Groups by @backstab5983 in https://github.com/Audionut/Upload-Assistant/pull/601 +* Fix filenames in Description when uploading TV [ ] by @Hielito2 in https://github.com/Audionut/Upload-Assistant/pull/603 +* Handles None imdb_id string by @jacobcxdev in https://github.com/Audionut/Upload-Assistant/pull/606 +* Fix variable reuse by @moontime-goose in https://github.com/Audionut/Upload-Assistant/pull/607 +* Add image restriction to DigitalCore by @PythonCoderAS in https://github.com/Audionut/Upload-Assistant/pull/609 +* Dp banned groups by @OrbitMPGH in https://github.com/Audionut/Upload-Assistant/pull/611 +* centralized language handling by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/604 +* Add randomness to image taking function and cleanup by @Hielito2 in https://github.com/Audionut/Upload-Assistant/pull/608 +* ASC - remove dependency on tracker API by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/610 +* BT - remove dependency on tracker API by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/612 +* Add LDU support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/613 +* Other fixes here and there. + +## New Contributors +* @jacobcxdev made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/606 +* @moontime-goose made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/607 +* @PythonCoderAS made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/609 +* @OrbitMPGH made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/611 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.4.1...5.1.5 +""" + +__version__ = "5.1.4.1" + +""" +Changelog for version 5.1.4.1 (2025-07-11): + +* Fix: string year for replacement. + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.4...5.1.4.1 +""" + +__version__ = "5.1.4" + +""" +Changelog for version 5.1.4 (2025-07-10): + +## What's Changed +* DP - remove image host requirements by @jschavey in https://github.com/Audionut/Upload-Assistant/pull/593 +* Fixed torf torrent creation when a single file from folder +* Fixed some year matching regex that was regressing title searching +* Fixed torrent id searching from support sites +* Updated ULCX banned groups and naming standards +* Updated BLU to use name as per IMDb + +## New Contributors +* @jschavey made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/593 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.3.1...5.1.4 +""" + +__version__ = "5.1.3.1" + +""" +Changelog for version 5.1.3.1 (2025-07-08): + +* Fixed disc based torrent creation + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.3...5.1.3.1 +""" + +__version__ = "5.1.3" + +""" +Changelog for version 5.1.3 (2025-07-08): + +* Fixed en checking in audio +* Fixed torrent links + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.2.4...5.1.3 +""" + +__version__ = "5.1.2.4" + +""" +Changelog for version 5.1.2.4 (2025-07-08): + +## What's Changed +* Update example-config.py by @backstab5983 in https://github.com/Audionut/Upload-Assistant/pull/589 +* Correct mediainfo validation + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.2.3...5.1.2.4 +""" + +__version__ = "5.1.2.3" + +""" +Changelog for version 5.1.2.3 (2025-07-07): + +## What's Changed +* region.py - add Pluto TV by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/583 +* Onlyimage by @edge20200 in https://github.com/Audionut/Upload-Assistant/pull/582 +* ASC - changes and fixes by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/581 +* Print cleaning and sanitation by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/580 +* HDS - description tweaks by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/585 +* (Update) ULCX banned groups by @AnabolicsAnonymous in https://github.com/Audionut/Upload-Assistant/pull/586 +* ASC - add custom layout config by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/584 +* Added support for DigitalCore by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/577 +* Fix upload to UTP by @IevgenSobko in https://github.com/Audionut/Upload-Assistant/pull/587 +* Fix torrent creation for foldered content to properly exclude bad files +* Validate Unique ID in mediainfo +* Cleaned up the UA presentation in console (see below) +* Refactored the dual/dubbed/bloated audio handling to catch some edge cases +* Fix linux dvd handling. maybe...... +* Updated auto torrent matching to catch more matches +* Run an auto config updater for edge's image host change +* Added a catch for incorrect tmdb id from BHD. Instead of allowing only an int for tmdb id, BHD changed to a string movie or tv/id arrangement, which means all manner of *plainly incorrect* ids can be returned from their API. +* Added language printing handling in descriptions using common.py, when language is not in mediainfo +* Added non-en dub warning, and skips for BHD/ULCX +* Changed -fl to be set at 100% by default +* Better auto IMDb edition handling +* Fixed an OE existing search bug that's been in the code since day dot +* Other little tweaks + +## Notes +Some large changes to the UA feedback during processing. Much more streamlined. +Two new config options: +* print_tracker_messages: False, - controls whether to print site api/html feedback on upload. +* print_tracker_links: True, - controls whether to print direct uploaded torrent links where possible. + +Even in debug mode, the console should now be sanitized of private details. There may be some edge cases, please report. + +## New Contributors +* @IevgenSobko made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/587 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.1...5.1.2 +""" + +__version__ = "5.1.1" + +""" +Changelog for version 5.1.1 (2025-06-28): + +## What's Changed +* HDT - screens and description changes by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/575 +* HDS - load custom descriptions by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/576 +* fix DVD processing on linux by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/574 +* ASC - improve fallback data by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/578 +* is_scene - Fix crash when is_all_lowercase is not defined by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/579 +* fixed the test run prints in the readme +* OTW - add resolution to name with DVD type sources +* BHD - nfo file uploads +* ULCX - fix search_year: aka - year in title when tv and condition met +* PTP - move the youtube check so that it only asks when actually uploading + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.1.0...5.1.1 +""" + +__version__ = "5.1.0" + +""" +Changelog for version 5.1.0 (2025-06-22): + +## What's Changed +* Updated get category function by @b-igu in https://github.com/Audionut/Upload-Assistant/pull/536 +* Set default value for FrameRate by @minicoz in https://github.com/Audionut/Upload-Assistant/pull/555 +* Update LCD.py by @a1Thiago in https://github.com/Audionut/Upload-Assistant/pull/562 +* DP - Fix: Subtitle language check ignores English by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/561 +* refactor id handling by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/548 +* make discord bot work by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/551 +* Added support for HD-Space by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/568 +* Added support for BrasilTracker by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/569 +* Added support for ASC by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/560 +* Properly restore key to original value by @ppkhoa in https://github.com/Audionut/Upload-Assistant/pull/573 +* OTW - update naming for DVD and REMUX +* Fixed an outlier is DVD source handling +* Fixed the discord bot to only load when being used and skip when debug +* Fixed existing image handling from PTP when not single files +* Added feedback when trackers were being skipped because of language checks +* Better dupe check handling for releases that only list DV when they're actually DV+HDR +* Fixed manual tag handling when anime +* Fixed only_id arg handling +* Fixed an aka bug from the last release that could skip aka +* Fixed double HC in HUNO name +* Added language checking for CBR +* Fixed only use tvdb if valid credentials + +## New Contributors +* @minicoz made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/555 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.5.1...5.1.0 +""" + +__version__ = "5.0.5.1" + +""" +Changelog for version 5.0.5.1 (2025-06-02): + +* Ensure proper category sets from sites + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.5...5.0.5.1 +""" + +__version__ = "5.0.5" + +""" +Changelog for version 5.0.5 (2025-06-02): + +## What's Changed +* CBR - Initial modq setup by @a1Thiago in https://github.com/Audionut/Upload-Assistant/pull/546 +* Remove 'pyrobase' requirement by @ambroisie in https://github.com/Audionut/Upload-Assistant/pull/547 +* DP - fixed to allow when en subs +* fixed cat set from auto unit3d +* updated AR naming to take either scene name or folder/file name. +* changed the aka diff check to only allow (automated) aka when difference is greater than 70% +* protect screenshots from ptp through bbcode shenanigans +* added some filtering for automated imdb edition handling + +## New Contributors +* @a1Thiago made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/546 +* @ambroisie made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/547 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.4.2...5.0.5 +""" + +__version__ = "5.0.4.2" + +""" +Changelog for version 5.0.4.2 (2025-05-30): + +* Fix the validation check when torrent_storage_dir + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.4.1...5.0.4.2 +""" + +__version__ = "5.0.4.1" + +""" +Changelog for version 5.0.4.1 (2025-05-30): + +* Fixed an issue from the last release that broke existing torrent validation in qbittorent +* DP - added modq option +* Better handling of REPACK detection +* Console cleaning +* Add Hybrid to filename detection + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.4...5.0.4.1 +""" + +__version__ = "5.0.4" + +""" +Changelog for version 5.0.4 (2025-05-28): + +## What's Changed +* Add additional arr instance support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/544 +* fixed anon arg +* fixed tvdb season/episode naming at HUNO +* fixed python title handling for edition and added some bad editions to skip +* fixed blank BHD descriptions also skipping images +* HDT - added quick skip for non-supported resolutions +* more tag regex shenanigans +* PTT - use only Polish name when original language is Polish (no aka) +* arr handling fixes +* PTP - if only_id, then skip if imdb_id != 0 +* reduced is_scene to one api all + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.3.3...5.0.4 +""" + +__version__ = "5.0.3.3" + +""" +Changelog for version 5.0.3.3 (2025-05-27): + +* Fix unnecessary error feedback on empty aither claims +* implement same for banned groups detection +* fix DVD error + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.3.2...5.0.3.3 +""" + +__version__ = "5.0.3.2" + +""" +Changelog for version 5.0.3.2 (2025-05-26): + +* Fix arr always return valid data + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.3.1...5.0.3.2 +""" + +__version__ = "5.0.3.1" + +""" +Changelog for version 5.0.3.1 (2025-05-26): + +* Fixed a bad await breaking HUNO + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.3...5.0.3.1 +""" + +__version__ = "5.0.3" + +""" +Changelog for version 5.0.3 (2025-05-26): + +## What's Changed +* update mediainfo by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/514 +* HUNO - naming update by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/535 +* add arr support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/538 +* Tracker specific custom link_dir and linking fallback by @brah in https://github.com/Audionut/Upload-Assistant/pull/537 +* Group tagging fixes +* Updated PTP url checking to catch old PTP torrent comments with non-ssl addy. (match more torrents) +* Whole bunch of console print cleaning +* Changed Limit Queue to only limit based on successful uploads +* Fixed PTP to not grab description in instances where it was not needed +* Set the TMP directory in docker to ensure description editing works in all cases +* Other little tweaks and fixes + +## NOTES +* Added specific mediainfo binary for DVD's. Update pymediainfo to use latest mediainfo for everything else. Defaulting to user installation because normal site-packages is not writeable +Collecting pymediainfo + Downloading pymediainfo-7.0.1-py3-none-manylinux_2_27_x86_64.whl.metadata (9.0 kB) +Downloading pymediainfo-7.0.1-py3-none-manylinux_2_27_x86_64.whl (6.0 MB) + ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 6.0/6.0 MB 100.6 MB/s eta 0:00:00 +Installing collected packages: pymediainfo +Successfully installed pymediainfo-7.0.1 +* With arr support, if the file is in your sonarr/radarr instance, it will pull data from the arr. +* Updated --webdv as the HYBRID title set. Works better than using --edition + +## New configs +* for tracker specific linking directory name instead of tracker acronym. +* to use original folder client injection model if linking failure. +* to keep description images when is True + +## New Contributors +* @brah made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/537 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.2...5.0.3 +""" + +__version__ = "5.0.2" + +""" +Changelog for version 5.0.2 (2025-05-20): + +- gather tmdb tasks to speed process +- add backup config to git ignore + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.1...5.0.2 +""" + +__version__ = "5.0.1" + +""" +Changelog for version 5.0.1 (2025-05-19): + +* Fixes DVD +* Fixes BHD description handling + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/5.0.0...5.0.1 +""" + +__version__ = "5.0.0" + +""" +Changelog for version 5.0.0 (2025-05-19): + +## A major version bump given some significant code changes + +## What's Changed +* Get edition from IMDB by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/519 +* Update LT.py by @Aerglonus in https://github.com/Audionut/Upload-Assistant/pull/520 +* (Add) mod queue opt-in option to OTW tracker by @AnabolicsAnonymous in https://github.com/Audionut/Upload-Assistant/pull/524 +* Add test run action by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/525 +* Prep is getting out of hand by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/518 +* Config generator and updater by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/522 +* Image rehosting use os.chdir as final fallback by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/529 +* Get edition from IMDB by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/519 +* Added a fallback to cover issue that causes glob to not find images when site rehosting images +* Fixed an issue that send dubbed as dual audio to MTV +* Fixed an issue when HDB descriptions returned None from bbcode cleaning +* Stopped using non-English names from TVDB when original language is not English +* Caught an error when TMDB is None from BHD +* Added function so that series packs can get TVDB name +* Other little tweaks and fixes + +## NOTES +- There is now a config generator and updater. config-generator.py. Usage is in the readme and docker wiki. As the name implies, you can generate new configs and update existing configs. +- If you are an existing user wanting to use the config-generator, I highly recommend to update your client names to match those set in the example-config https://github.com/Audionut/Upload-Assistant/blob/5f27e01a7f179e0ea49796dcbcae206718366423/data/example-config.py#L551 +- The names that match what you set as the default_torrent_client https://github.com/Audionut/Upload-Assistant/blob/5f27e01a7f179e0ea49796dcbcae206718366423/data/example-config.py#L140 +- This will make your experience with the config-generator much more pleasurable. +- BHD api/rss keys for BHD id/description parsing are now located with the BHD tracker settings and not within the DEFAULT settings section. It will continue to work with a notice being printed for the meantime, but please update your configs as I will permanently retire the old settings in time. +- modq for UNIT3D sites has been fixed in the UNIT3D source thanks to @AnabolicsAnonymous let me know if a site you use has updated to the latest UNIT3D source code with modq api fix, and it can be added to that sites UA file. +- You may notice that the main landing page now contains some Test Run passing displays. This does some basic checking that won't catch every error, but it may be useful for those who update directly from master branch. I'll keep adding to this over time to better catch any errors, If this display shows error, probably don't git pull. + +## New Contributors +* @Aerglonus made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/520 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.4.1...5.0.0 +""" + +__version__ = "4.2.4.1" + +""" +Changelog for version 4.2.4.1 (2025-05-10): + +## What's Changed +* Make search imdb not useless by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/517 +* Remove brackets from TVDB titles +* Fix PTP adding group. + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.4...4.2.4.1 +""" + +__version__ = "4.2.4" + +""" +Changelog for version 4.2.4 (2025-05-10): + +## What's Changed +* Update PTT.py by @btTeddy in https://github.com/Audionut/Upload-Assistant/pull/511 +* Update OTW banned release groups by @backstab5983 in https://github.com/Audionut/Upload-Assistant/pull/512 +* tmdb from imdb updates by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/515 +* Use TVDB title by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/516 +* HDB descriptions by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/498 +* Fixed manual frame code changes breaking packed images handling +* DP - removed nordic from name per their request +* Fixed PTP groupID not being set in meta +* Added a config option for screenshot header when tonemapping + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.3.1...4.2.4 +""" + +__version__ = "4.2.3.1" + +""" +Changelog for version 4.2.3.1 (2025-05-05): + +* Fix cat call + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.3...4.2.3.1 +""" + +__version__ = "4.2.3" + +""" +Changelog for version 4.2.3 (2025-05-05): + +## What's Changed +* Update PSS banned release groups by @backstab5983 in https://github.com/Audionut/Upload-Assistant/pull/504 +* Add BR streaming services by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/505 +* Fixed PTP manual concert type +* Fixed PTP trump/subs logic (again) +* Fixed PT that I broke when fixing PTT +* Catch imdb str id from HUNO +* Skip auto PTP searching if TV - does not effect manual ID or client searching + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.2...4.2.3 +""" + +__version__ = "4.2.2" + +""" +Changelog for version 4.2.2 (2025-05-03): + +## What's Changed +* Update Service Mapping NOW by @yoyo292949158 in https://github.com/Audionut/Upload-Assistant/pull/494 +* (Add) mod queue opt-in option to ULCX tracker by @AnabolicsAnonymous in https://github.com/Audionut/Upload-Assistant/pull/491 +* Fix typo in HDB comps by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/492 +* Check lowercase names against srrdb for proper tag by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/495 +* Additional bbcode editing on PTP/HDB/BHD/BLU by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/493 +* Further bbcode conversions by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/496 +* Stop convert_comparison_to_centered to crush spaces in names by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/500 +* TOCA remove EUR as region by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/501 +* CBR - add dvdrip by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/502 +* CBR - aka and year updats for name by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/503 +* Added validation to BHD description images +* Fixed an issue with PTP/THR when no IMDB +* BHD/AR graceful error handling +* Fix PTT tracker setup +* Added 'hd.ma.5.1' as a bad group tag to skip + +## New Contributors +* @AnabolicsAnonymous made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/491 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.1...4.2.2 +""" + +__version__ = "4.2.1" + +""" +Changelog for version 4.2.1 (2025-04-29): + +## What's Changed +* Update RAS.py by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/483 +* Add support for Portugas by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/482 +* OTW - use year in TV title by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/481 +* Adding ADN as a provider by @ppkhoa in https://github.com/Audionut/Upload-Assistant/pull/484 +* Allow '-s 0' option when uploading to HDB by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/485 +* CBR: Refactor get_audio function to handle multiple languages by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/488 +* Screens handling updates by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/486 +* Add comparison images by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/487 +* Should be improvements to PTP hardcoded subs handling +* Corrected AR imdb url +* Fixed an issue in a tmdb episode pathway that would fail without tvdb +* Cleaned more private details from debug prints +* Fixed old BHD code to respect only supported BDMV regions +* Update OE against their image hosts rule +* Added passtheima.ge support + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.0.1...4.2.1 +""" + +__version__ = "4.2.0.1" + +""" +Changelog for version 4.2.0.1 (2025-04-24): + +- OE - only allow with English subs if not English audio +- Fixed the bad copy/paste that missed the ULCX torrent url +- Added the new trackers args auto api to example config +- Fixed overwriting custom descriptions with bad data +- Updated HDR check to find and correctly check for relevant strings. + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.2.0...4.2.0.1 +""" + +__version__ = "4.2.0" + +""" +Changelog for version 4.2.0 (2025-04-24): + +## What's Changed +* store and use any found torrent data by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/452 +* Automated bluray region-distributor parsing by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/471 +* add image upload retry logic by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/472 +* TVC Allow 1080p HEVC by @yoyo292949158 in https://github.com/Audionut/Upload-Assistant/pull/478 +* Small fixes to AL title formatting by @b-igu in https://github.com/Audionut/Upload-Assistant/pull/477 +* fixed a bug that skipped tvdb episode data handling +* made THR work + +## Config additions +* A bunch of new config options starting here: https://github.com/Audionut/Upload-Assistant/blob/b382ece4fde22425dd307d1098198fb3fc9e0289/data/example-config.py#L183 + +## New Contributors +* @yoyo292949158 made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/478 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.9...4.2.0 +""" + +__version__ = "4.1.9" + +""" +Changelog for version 4.1.9 (2025-04-20): + +## What's Changed +* PTP. Do not ask if files with en-GB subs are trumpable. by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/459 +* Add tag for releases without a group name (PSS) by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/461 +* In PTP descriptions, do not replace [code] by [quote]. by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/463 +* In HDB descriptions, do not replace [code] by [quote]. by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/466 +* handle cleanup on mac os without termination by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/465 +* Refactor CBR.py by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/467 +* Description Customization by @zercsy in https://github.com/Audionut/Upload-Assistant/pull/468 +* Fixed THR +* Added an option that allows sites to skip upload when content does not contain English +* Fixed cleanup on Mac OS +* Fixed an error causing regenerated torrents to fail being added to client +* Added fallback search for HDB when no IMDB +* Other minor fixes + +## New Contributors +* @zercsy made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/468 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.8.1...4.1.9 +""" + +__version__ = "4.1.8.1" + +""" +Changelog for version 4.1.8.1 (2025-04-15): + +* Fixed a quote bug + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.8...4.1.8.1 +""" + +__version__ = "4.1.8" + +""" +Changelog for version 4.1.8 (2025-04-14): + +## What's Changed +* Correct typo to enable UA to set the 'Internal' tag on HDB. by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/456 +* Updated AL upload by @b-igu in https://github.com/Audionut/Upload-Assistant/pull/457 +* Run cleaning between items in a queue - fixes terminal issue when running a queue +* Fixed an error when imdb returns no results +* Fixes image rehosting was overwriting main image_list + +## New Contributors +* @b-igu made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/457 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.7...4.1.8 +""" + +__version__ = "4.1.7" + +""" +Changelog for version 4.1.7 (2025-04-13): + +## What's Changed +* Fix missing HHD config in example-config.py by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/455 +* Updated mkbrr including fix for BDMV torrent and symlink creation +* Fixed manual source with BHD +* Added nfo file upload support for DP +* Changed logo handling so individual sites can pull language specific logos +* Fixed an error with adding mkbrr regenerated torrents to client +* Refactored Torf torrent creation to be quicker + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.6...4.1.7 +""" + +__version__ = "4.1.6" + +""" +Changelog for version 4.1.6 (2025-04-12): + +## What's Changed +* qBittorrent Option: Include Tracker as Tag - New sites SAM and UHD by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/454 +* fixed image retaking +* fixed pack images to be saved in unique file now that meta is deleted by default +* updated OE to check all mediainfo when language checking +* updated OTW to include resolution with DVD +* updated DP rule compliance + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.5...4.1.6 +""" + +__version__ = "4.1.5" + +""" +Changelog for version 4.1.5 (2025-04-10): + +## What's Changed +* Clean existing meta by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/451 +* Added frame overlays to disc based content +* Refactored ss_times + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.4.1...4.1.5 +""" + +__version__ = "4.1.4.1" + +""" +Changelog for version 4.1.4.1 (2025-04-09): + +## What's Changed +* Minor fixes in TIK.py by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/449 +* Fixed year getting inserted into incorrect TV + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.4...4.1.4.1 +""" + +__version__ = "4.1.4" + +""" +Changelog for version 4.1.4 (2025-04-08): + +## What's Changed +* Update SP.py to replace with . per upload guidelines by @tubaboy26 in https://github.com/Audionut/Upload-Assistant/pull/435 +* HUNO - remove region from name by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/441 +* Correct absolute episode number lookup by @ppkhoa in https://github.com/Audionut/Upload-Assistant/pull/447 +* add more args overrides options by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/437 +* add rTorrent linking support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/390 +* Accept both relative and absolute path for the description filename. by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/448 +* Updated dupe checking - mainly to allow uploads when more than 1 of a content is allowed +* Added an argument which cleans just the tmp directory for the current pathed content +* Hide some not important console prints behind debug +* Fixed HDR tonemapping +* Added config option to overlay some details on screenshots (currently only files) +* Adjust font size of screenshot overlays to match the resolution. by @GizmoBal in https://github.com/Audionut/Upload-Assistant/pull/442 +* Fixed manual year +* Other minor fixes + +## New Contributors +* @GizmoBal made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/442 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.3...4.1.4 +""" + +__version__ = "4.1.3" + +""" +Changelog for version 4.1.3 (2025-04-02): + +- All torrent creation issues should now be fixed +- Site upload issues are gracefully handled +- tvmaze episode title fallback +- Fix web/hdtv dupe handling + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.2...4.1.3 +""" + +__version__ = "4.1.2" + +""" +Changelog for version 4.1.2 (2025-03-30): + +## What's Changed +* Added support for DarkPeers and Rastastugan by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/431 +* fixed HDB missing call for torf regeneration +* fixed cutoff screens handling when taking images +* fixed existing image timeout error causing UA to hard crash +* tweaked pathway to ensure no duplicate api calls +* fixed a duplicate import in PTP that could cause some python versions to hard error +* removed JPTV + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.1...4.1.2 +""" + +__version__ = "4.1.1" + +""" +Changelog for version 4.1.1 (2025-03-30): + +## What's Changed +* add argument --not-anime by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/430 +* fixed linking on linux when volumes have the same mount +* fixed torf torrent regeneration in MTV +* added null language check for tmdb logo (mostly useful for movies) +* fixed +* fixed ssrdb release matching print +* fixed tvdb season matching under some conditions (wasn't serious) + +Check v4.1.0 release notes if not already https://github.com/Audionut/Upload-Assistant/releases/tag/4.1.0 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.0.2...4.1.1 +""" + +__version__ = "4.1.0.2" + +""" +Changelog for version 4.1.0.2 (2025-03-29): + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.0.1...4.1.0.2 + +4..1.0 release notes: + +## New config options +See example-config.py +- and - add tv series logo to top of descriptions with size control +- - from the last release, adds tv series overview to description. Now includes season name and details if applicable, see below +- (qBitTorrent v5+ only) - don't automatically try and find a matching torrent from just the path +- and for tvdb data support + +## Notes +- UA will now try and automatically find a torrent from qBitTorrent (v5+ only) that matches any site based argument. If it finds a matching torrent, for instance from PTP, it will automatically set . In other words, you no longer need to set a site argument ( or or --whatever (or and/or ) as UA will now do this automatically if the path matches a torrent in client. Use the applicable config option to disable this default behavior. + +- TVDB requires token to be initially inputted, after which time it will be auto generated as needed. +- Automatic Absolute Order to Aired Order season/episode numbering with TVDB. +- BHD now supports torrent id instead of just hash. +- Some mkbrr updates, including support for and rehashing for sites as needed. +- TMDB searching should be improved. + + +See examples below for new logo and episode data handling. + + + +## What's Changed +* BHD torrent id parsing by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/417 +* Better title/year parsing for tmdb searching by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/416 +* feat: pull logo from tmdb by @markhc in https://github.com/Audionut/Upload-Assistant/pull/425 +* fix: logo displayed as None by @markhc in https://github.com/Audionut/Upload-Assistant/pull/427 +* Update region.py by @ikitub3 in https://github.com/Audionut/Upload-Assistant/pull/429 +* proper mkbrr handling by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/397 +* TVDB support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/423 +* qBitTorrent auto torrent grabing and rTorrent infohash support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/428 + +## New Contributors +* @markhc made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/425 +* @ikitub3 made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/429 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.6...4.1.0 +""" + +__version__ = "4.1.0.1" + +""" +Changelog for version 4.1.0.1 (2025-03-29): + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.1.0...4.1.0.1 + +From 4.1.0 + +## New config options +See example-config.py +- and - add tv series logo to top of descriptions with size control +- - from the last release, adds tv series overview to description. Now includes season name and details if applicable, see below +- (qBitTorrent v5+ only) - don't automatically try and find a matching torrent from just the path +- and for tvdb data support + +## Notes +- UA will now try and automatically find a torrent from qBitTorrent (v5+ only) that matches any site based argument. If it finds a matching torrent, for instance from PTP, it will automatically set . In other words, you no longer need to set a site argument ( or or --whatever (or and/or ) as UA will now do this automatically if the path matches a torrent in client. Use the applicable config option to disable this default behavior. + +- TVDB requires token to be initially inputted, after which time it will be auto generated as needed. +- Automatic Absolute Order to Aired Order season/episode numbering with TVDB. +- BHD now supports torrent id instead of just hash. +- Some mkbrr updates, including support for and rehashing for sites as needed. +- TMDB searching should be improved. + + +See examples below for new logo and episode data handling. + + + +## What's Changed +* BHD torrent id parsing by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/417 +* Better title/year parsing for tmdb searching by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/416 +* feat: pull logo from tmdb by @markhc in https://github.com/Audionut/Upload-Assistant/pull/425 +* fix: logo displayed as None by @markhc in https://github.com/Audionut/Upload-Assistant/pull/427 +* Update region.py by @ikitub3 in https://github.com/Audionut/Upload-Assistant/pull/429 +* proper mkbrr handling by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/397 +* TVDB support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/423 +* qBitTorrent auto torrent grabing and rTorrent infohash support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/428 + +## New Contributors +* @markhc made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/425 +* @ikitub3 made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/429 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.6...4.1.0 +""" + +__version__ = "4.1.0" + +""" +Changelog for version 4.1.0 (2025-03-29): + +## New config options +See example-config.py +- and - add tv series logo to top of descriptions with size control +- - from the last release, adds tv series overview to description. Now includes season name and details if applicable, see below +- (qBitTorrent v5+ only) - don't automatically try and find a matching torrent from just the path +- and for tvdb data support + +## Notes +- UA will now try and automatically find a torrent from qBitTorrent (v5+ only) that matches any site based argument. If it finds a matching torrent, for instance from PTP, it will automatically set . In other words, you no longer need to set a site argument ( or or --whatever (or and/or ) as UA will now do this automatically if the path matches a torrent in client. Use the applicable config option to disable this default behavior. + +- TVDB requires token to be initially inputted, after which time it will be auto generated as needed. +- Automatic Absolute Order to Aired Order season/episode numbering with TVDB. +- BHD now supports torrent id instead of just hash. +- Some mkbrr updates, including support for and rehashing for sites as needed. +- TMDB searching should be improved. + + +See examples below for new logo and episode data handling. + + + +## What's Changed +* BHD torrent id parsing by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/417 +* Better title/year parsing for tmdb searching by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/416 +* feat: pull logo from tmdb by @markhc in https://github.com/Audionut/Upload-Assistant/pull/425 +* fix: logo displayed as None by @markhc in https://github.com/Audionut/Upload-Assistant/pull/427 +* Update region.py by @ikitub3 in https://github.com/Audionut/Upload-Assistant/pull/429 +* proper mkbrr handling by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/397 +* TVDB support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/423 +* qBitTorrent auto torrent grabing and rTorrent infohash support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/428 + +## New Contributors +* @markhc made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/425 +* @ikitub3 made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/429 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.6...4.1.0 +""" + +__version__ = "4.0.6" + +""" +Changelog for version 4.0.6 (2025-03-25): + +## What's Changed +* update to improve 540 detection by @swannie-eire in https://github.com/Audionut/Upload-Assistant/pull/413 +* Update YUS.py by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/414 +* BHD - file/folder searching by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/415 +* Allow some hardcoded user overrides by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/411 +* option episode overview in description by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/418 +* Catch HUNO BluRay naming requirement by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/419 +* group tag regex by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/420 +* OTW - stop pre-filtering image hosts +* revert automatic episode title + +BHD auto searching does not currently return description/image links + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.5...4.0.6 +""" + +__version__ = "4.0.5" + +""" +Changelog for version 4.0.5 (2025-03-21): + +## What's Changed +* Refactor TOCA.py by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/410 +* fixed an imdb search returning bad results +* don't run episode title checks on season packs or episode == 0 +* cleaned PTP mediainfo in packed content (scrubbed by PTP upload parser anyway) +* fixed some sites duplicating episode title +* docker should only pull needed mkbrr binaries, not all of them +* removed private details from some console prints +* fixed handling in ptp mediainfo check +* fixed arg work with no value +* removed rehosting from OTW, they seem fine with ptpimg now. + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.4...4.0.5 +""" + +__version__ = "4.0.4" + +""" +Changelog for version 4.0.4 (2025-03-19): + +## What's Changed +* get episode title from tmdb by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/403 +* supporting 540p by @swannie-eire in https://github.com/Audionut/Upload-Assistant/pull/404 +* LT - fix no distributor api endpoint by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/406 +* reset terminal fix +* ULCX content checks +* PTP - set EN sub flag when trumpable for HC's English subs +* PTP - fixed an issue where description images were not being parsed correctly +* Caught an IMDB issue when no IMDB is returned by metadata functions +* Changed the banned groups/claims checking to daily + +## Episode title data change +Instead of relying solely on guessit to catch episode titles, UA now pulls episode title information from TMDB. There is some pre-filtering to catch placeholder title information like 'Episode 2', but you should monitor your TV uploads. Setting with an empty space will clear the episode title. + +Conversely (reminder of already existing functionality), setting met with some title will force that episode title. + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.3.1...4.0.4 +""" + +__version__ = "4.0.3.1" + +""" +Changelog for version 4.0.3.1 (2025-03-17): + +- Fix erroneous AKA in title when AKA empty + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.3...4.0.3.1 +""" + +__version__ = "4.0.3" + +""" +Changelog for version 4.0.3 (2025-03-17): + +## What's Changed +* Update naming logic for SP Anime Uploads by @tubaboy26 in https://github.com/Audionut/Upload-Assistant/pull/399 +* Fix ITT torrent comment by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/400 +* Fix --cleanup without path +* Fix tracker casing +* Fix AKA + +## New Contributors +* @tubaboy26 made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/399 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.2...4.0.3 +""" + +__version__ = "4.0.2" + +""" +Changelog for version 4.0.2 (2025-03-15): + +## What's Changed +* Update CBR.py by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/392 +* Update ITT.py by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/393 +* Added support for TocaShare by @wastaken7 in https://github.com/Audionut/Upload-Assistant/pull/394 +* Force auto torrent management to false when using linking + +## New Contributors +* @wastaken7 made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/392 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.1...4.0.2 +""" + +__version__ = "4.0.1" + +""" +Changelog for version 4.0.1 (2025-03-14): + +- fixed a tracker handling error when answering no to title confirmation +- fixed imdb from srrdb +- strip matching distributor from title and add to meta object +- other little fixes + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.0.3...4.0.1 +""" + +__version__ = "4.0.0.3" + +""" +Changelog for version 4.0.0.3 (2025-03-13): + +- added platform to docker building +- fixed anime titling +- fixed aither dvdrip naming + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.0.2...4.0.0.3 + +## Version 4 release notes: +## Breaking change +* When using trackers argument, or , you must now use a comma separated list. + +## Linking support in qBitTorrent +### This is not fully tested. +It seems to be working fine on this windows box, but you absolutely should test with the argument to make sure it works on your system before putting it into production. +* You can specify to use symbolic or hard links +* +* Add one or many (local) paths which you want to contain the links, and UA will map the correct drive/volume for hardlinks. + +## Reminder +* UA has mkbrr support +* You can specify an argument or set the config +* UA loads binary files for the supported mkbrr OS. If you find mkbrr slower than the original torf implementation when hashing torrents, the mkbrr devs are likely to be appreciative of any reports. +""" + +__version__ = "4.0.0.2" + +""" +Changelog for version 4.0.0.2 (2025-03-13): + +- two site files manually imported tmdbsimple. +- fixed R4E by adding the want tmdb data from the main tmdb api call, which negates the need to make a needless api call when uploading to R4E, and will shave around 2 seconds from the time it takes to upload. +- other site file will be fixed when I get around to dealing with that mess. + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.0.1...4.0.0.2 + +## Version 4 release notes: +## Breaking change +* When using trackers argument, or , you must now use a comma separated list. + +## Linking support in qBitTorrent +### This is not fully tested. +It seems to be working fine on this windows box, but you absolutely should test with the argument to make sure it works on your system before putting it into production. +* You can specify to use symbolic or hard links +* +* Add one or many (local) paths which you want to contain the links, and UA will map the correct drive/volume for hardlinks. + +## Reminder +* UA has mkbrr support +* You can specify an argument or set the config +* UA loads binary files for the supported mkbrr OS. If you find mkbrr slower than the original torf implementation when hashing torrents, the mkbrr devs are likely to be appreciative of any reports. +""" + +__version__ = "4.0.0.1" + +""" +Changelog for version 4.0.0.1 (2025-03-13): + +- fix broken trackers handling +- fix client inject when not using linking. + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/4.0.0...4.0.0.1 + +## Version 4 release notes: +## Breaking change +* When using trackers argument, or , you must now use a comma separated list. + +## Linking support in qBitTorrent +### This is not fully tested. +It seems to be working fine on this windows box, but you absolutely should test with the argument to make sure it works on your system before putting it into production. +* You can specify to use symbolic or hard links +* +* Add one or many (local) paths which you want to contain the links, and UA will map the correct drive/volume for hardlinks. + +## Reminder +* UA has mkbrr support +* You can specify an argument or set the config +* UA loads binary files for the supported mkbrr OS. If you find mkbrr slower than the original torf implementation when hashing torrents, the mkbrr devs are likely to be appreciative of any reports. +""" + +__version__ = "4.0.0" + +""" +Changelog for version 4.0.0 (2025-03-13): + +Pushing this as v4 given some significant code changes. + +## Breaking change +* When using trackers argument, or , you must now use a comma separated list. + +## Linking support in qBitTorrent +### This is not fully tested. +It seems to be working fine on this windows box, but you absolutely should test with the argument to make sure it works on your system before putting it into production. +* You can specify to use symbolic or hard links +* +* Add one or many (local) paths which you want to contain the links, and UA will map the correct drive/volume for hardlinks. + +## Reminder +* UA has mkbrr support +* You can specify an argument or set the config +* UA loads binary files for the supported mkbrr OS. If you find mkbrr slower than the original torf implementation when hashing torrents, the mkbrr devs are likely to be appreciative of any reports. + +## What's Changed +* move cleanup to file by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/384 +* async metadata calls by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/382 +* add initial linking support by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/380 +* Refactor args parsing by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/383 + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.6.5...4.0.0 +""" + +__version__ = "3.6.5" + +""" +Changelog for version 3.6.5 (2025-03-12): + +## What's Changed +* bunch of id related issues fixed +* if using , take that moment to validate and export the torrent file +* some prettier printing with torf torrent hashing +* mkbrr binary files by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/381 + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.6.4...3.6.5 +""" + +__version__ = "3.6.4" + +""" +Changelog for version 3.6.4 (2025-03-09): + +- Added option to use mkbrr https://github.com/autobrr/mkbrr (). About 4 times faster than torf for a file in cache . Can be set via config +- fixed empty HDB file/folder searching giving bad feedback print + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.6.3.1...3.6.4 +""" + +__version__ = "3.6.3.1" + +""" +Changelog for version 3.6.3.1 (2025-03-09): + +- Fix BTN ID grabbing + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.6.3...3.6.3.1 +""" + +__version__ = "3.6.3" + +""" +Changelog for version 3.6.3 (2025-03-09): + +## Config changes +* As part of the effort to fix unresponsive terminals on unix systems, a new config option has been added , and an existing config option , now has a default setting even if commented out/not preset. +* Non-unix users (or users without terminal issue) should uncomment and modify these settings to taste +* https://github.com/Audionut/Upload-Assistant/blob/de7689ff36f76d7ba9b92afe1175b703a59cda65/data/example-config.py#L53 + +## What's Changed +* Create YUS.py by @fiftieth3322 in https://github.com/Audionut/Upload-Assistant/pull/373 +* remote_path as list by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/365 +* Correcting PROPER number namings in title by @Zips-sipZ in https://github.com/Audionut/Upload-Assistant/pull/378 +* Save extracted description images to disk (can be useful for rehosting to save the capture/optimization step) +* Updates/fixes to ID handling across the board +* Catch session interruptions in AR to ensure session is closed +* Work around a bug that sets empty description to None, breaking repeated processing with same meta +* Remote paths now accept list +* More effort to stop unix terminals shitting the bed + +## New Contributors +* @fiftieth3322 made their first contribution in https://github.com/Audionut/Upload-Assistant/pull/373 + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.6.2...3.6.3 +""" + +__version__ = "3.6.2" + +""" +Changelog for version 3.6.2 (2025-03-04): + +## Update Notification +This release adds some new config options relating to update notifications: https://github.com/Audionut/Upload-Assistant/blob/a8b9ada38323c2f05b0f808d1d19d1d79c2a9acf/data/example-config.py#L9 + +## What's Changed +* Add proper2 and proper3 support by @Kha-kis in https://github.com/Audionut/Upload-Assistant/pull/371 +* added update notification +* HDB image rehosting updates +* updated srrdb handling +* other minor fixes + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.6.1...3.6.2 +""" + +__version__ = "3.6.1" + +""" +Changelog for version 3.6.1 (2025-03-01): + +- fix manual package screens uploading +- switch to subprocess for setting stty sane +- print version to console +- other minor fixes + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.6.0...3.6.1 +""" + +__version__ = "3.6.0" + +""" +Changelog for version 3.6.0 (2025-02-28): + +## What's Changed +* cleanup tasks by @Audionut in https://github.com/Audionut/Upload-Assistant/pull/364 + + +**Full Changelog**: https://github.com/Audionut/Upload-Assistant/compare/3.5.3.3...3.6.0 +""" + +__version__ = "3.5.3.1" diff --git a/discordbot.py b/discordbot.py index 4e6e6d3ae..c017f41c3 100644 --- a/discordbot.py +++ b/discordbot.py @@ -1,46 +1,47 @@ import asyncio import datetime -import json import logging -import configparser from pathlib import Path - +from src.console import console import discord from discord.ext import commands - - -def config_load(): - # Python Config - from data.config import config - return config - -async def run(): +async def run(config): """ - Where the bot gets started. If you wanted to create an database connection pool or other session for the bot to use, - it's recommended that you create it here and pass it to the bot as a kwarg. + Starts the bot. If you want to create a database connection pool or other session for the bot to use, + create it here and pass it to the bot as a kwarg. """ - config = config_load() - bot = Bot(config=config, - description=config['DISCORD']['discord_bot_description']) + intents = discord.Intents.default() + intents.message_content = True + + bot = Bot( + config=config, + description=config['DISCORD']['discord_bot_description'], + intents=intents + ) + try: await bot.start(config['DISCORD']['discord_bot_token']) except KeyboardInterrupt: - await bot.logout() + await bot.close() class Bot(commands.Bot): - def __init__(self, **kwargs): + def __init__(self, *, config, description, intents): super().__init__( command_prefix=self.get_prefix_, - description=kwargs.pop('description') + description=description, + intents=intents ) self.start_time = None self.app_info = None - self.config = config_load() - self.loop.create_task(self.track_start()) - self.loop.create_task(self.load_all_extensions()) + self.config = config + + async def setup_hook(self): + # Called before the bot connects to Discord + asyncio.create_task(self.track_start()) + await self.load_all_extensions() async def track_start(self): """ @@ -53,9 +54,6 @@ async def track_start(self): async def get_prefix_(self, bot, message): """ A coroutine that returns a prefix. - - I have made this a coroutine just to show that it can be done. If you needed async logic in here it can be done. - A good example of async logic would be retrieving a prefix from a database. """ prefix = [self.config['DISCORD']['command_prefix']] return commands.when_mentioned_or(*prefix)(bot, message) @@ -69,13 +67,12 @@ async def load_all_extensions(self): cogs = [x.stem for x in Path('cogs').glob('*.py')] for extension in cogs: try: - self.load_extension(f'cogs.{extension}') + await self.load_extension(f'cogs.{extension}') print(f'loaded {extension}') except Exception as e: error = f'{extension}\n {type(e).__name__} : {e}' print(f'failed to load extension {error}') print('-' * 10) - async def on_ready(self): """ @@ -88,26 +85,111 @@ async def on_ready(self): f'Owner: {self.app_info.owner}\n') print('-' * 10) channel = self.get_channel(int(self.config['DISCORD']['discord_channel_id'])) - await channel.send(f'{self.user.name} is now online') + if channel: + await channel.send(f'{self.user.name} is now online') async def on_message(self, message): """ This event triggers on every message received by the bot. Including one's that it sent itself. - - If you wish to have multiple event listeners they can be added in other cogs. All on_message listeners should - always ignore bots. """ if message.author.bot: return # ignore all bots await self.process_commands(message) - - +async def send_discord_notification(config, bot, message, debug=False, meta=None): + """ + Send a notification message to Discord channel. + + Args: + bot: Discord bot instance (can be None) + message: Message string to send + meta: Optional meta dict for debug logging + + Returns: + bool: True if message was sent successfully, False otherwise + """ + only_unattended = config.get('DISCORD', {}).get('only_unattended', False) + if only_unattended and meta and not meta.get('unattended', False): + return False + if not bot or not hasattr(bot, 'is_ready') or not bot.is_ready(): + if debug: + console.print("[yellow]Discord bot not ready - skipping notifications") + return False + + try: + channel_id = int(config['DISCORD']['discord_channel_id']) + channel = bot.get_channel(channel_id) + if channel: + await channel.send(message) + if debug: + console.print(f"[green]Discord notification sent: {message}") + return True + else: + console.print("[yellow]Discord channel not found") + return False + except Exception as e: + console.print(f"[yellow]Discord notification error: {e}") + return False + + +async def send_upload_status_notification(config, bot, meta): + """Send Discord notification with upload status including failed trackers.""" + only_unattended = config.get('DISCORD', {}).get('only_unattended', False) + if only_unattended and meta and not meta.get('unattended', False): + return False + if not bot or not hasattr(bot, 'is_ready') or not bot.is_ready(): + return False + + tracker_status = meta.get('tracker_status', {}) + if not tracker_status: + return False + + # Get list of trackers where upload is True + successful_uploads = [ + tracker for tracker, status in tracker_status.items() + if status.get('upload', False) + ] + + # Get list of failed trackers with reasons + failed_trackers = [] + for tracker, status in tracker_status.items(): + if not status.get('upload', False): + if status.get('banned', False): + failed_trackers.append(f"{tracker} (banned)") + elif status.get('skipped', False): + failed_trackers.append(f"{tracker} (skipped)") + elif status.get('dupe', False): + failed_trackers.append(f"{tracker} (dupe)") + + release_name = meta.get('name', meta.get('title', 'Unknown Release')) + message_parts = [] + + if successful_uploads: + message_parts.append(f"✅ **Uploaded to:** {', '.join(successful_uploads)} - {release_name}") + + if failed_trackers: + message_parts.append(f"❌ **Failed:** {', '.join(failed_trackers)}") + + if not message_parts: + return False + + message = "\n".join(message_parts) + + try: + channel_id = int(config['DISCORD']['discord_channel_id']) + channel = bot.get_channel(channel_id) + if channel: + await channel.send(message) + return True + except Exception as e: + console.print(f"[yellow]Discord notification error: {e}") + + return False if __name__ == '__main__': + # Only used when running discordbot.py directly + from data.config import config logging.basicConfig(level=logging.INFO) - - loop = asyncio.get_event_loop() - loop.run_until_complete(run()) + asyncio.run(run(config)) diff --git a/requirements.txt b/requirements.txt index 19e7c5038..4365a8630 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,21 +1,37 @@ -torf -guessit -ffmpeg-python -pymediainfo -tmdbsimple +aiofiles +aiohttp anitopy +bbcode +beautifulsoup4 +bencode.py cli-ui -qbittorrent-api +click deluge-client -pyrobase -requests -cinemagoer -pyimgbox +discord +ffmpeg-python +guessit +httpx +Jinja2 +langcodes +lxml nest_asyncio -bencode.py -unidecode -beautifulsoup4 +packaging +Pillow +psutil +pycountry +pyimgbox +pymediainfo; sys_platform == "win32" +pymediainfo==6.0.1; sys_platform == "darwin" +pymediainfo==6.0.1; sys_platform == "linux" +pyotp pyoxipng +pyparsebluray +qbittorrent-api +requests rich -Jinja2 -pyotp \ No newline at end of file +tmdbsimple +torf +tqdm +transmission_rpc +unidecode +urllib3 diff --git a/src/add_comparison.py b/src/add_comparison.py new file mode 100644 index 000000000..0fb19b7d4 --- /dev/null +++ b/src/add_comparison.py @@ -0,0 +1,125 @@ +import os +import re +import json +import cli_ui +from collections import defaultdict +from src.uploadscreens import upload_screens +from data.config import config +from src.console import console + + +async def add_comparison(meta): + comparison_path = meta.get('comparison') + if not comparison_path or not os.path.isdir(comparison_path): + return [] + + comparison_data_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/comparison_data.json" + if os.path.exists(comparison_data_file): + try: + with open(comparison_data_file, 'r') as f: + saved_comparison_data = json.load(f) + if meta.get('debug'): + console.print(f"[cyan]Loading previously saved comparison data from {comparison_data_file}") + meta["comparison_groups"] = saved_comparison_data + + comparison_index = meta.get('comparison_index') + if comparison_index and comparison_index in saved_comparison_data: + if 'image_list' not in meta: + meta['image_list'] = [] + + urls_to_add = saved_comparison_data[comparison_index].get('urls', []) + if meta.get('debug'): + console.print(f"[cyan]Adding {len(urls_to_add)} images from comparison group {comparison_index} to image_list") + + for url_info in urls_to_add: + if url_info not in meta['image_list']: + meta['image_list'].append(url_info) + + return saved_comparison_data + except Exception as e: + console.print(f"[yellow]Error loading saved comparison data: {e}") + + files = [f for f in os.listdir(comparison_path) if f.lower().endswith('.png')] + pattern = re.compile(r"(\d+)-(\d+)-(.+)\.png", re.IGNORECASE) + + groups = defaultdict(list) + suffixes = {} + + for f in files: + match = pattern.match(f) + if match: + first, second, suffix = match.groups() + groups[second].append((int(first), f)) + if second not in suffixes: + suffixes[second] = suffix + + meta_comparisons = {} + img_host_keys = [k for k in config.get('DEFAULT', {}) if k.startswith('img_host_')] + img_host_indices = [int(k.split('_')[-1]) for k in img_host_keys] + img_host_indices.sort() + + if not img_host_indices: + raise ValueError("No image hosts found in config. Please ensure at least one 'img_host_X' key is present in config.") + + for idx, second in enumerate(sorted(groups, key=lambda x: int(x)), 1): + img_host_num = img_host_indices[0] + current_img_host_key = f'img_host_{img_host_num}' + current_img_host = config.get('DEFAULT', {}).get(current_img_host_key) + + group = sorted(groups[second], key=lambda x: x[0]) + group_files = [f for _, f in group] + custom_img_list = [os.path.join(comparison_path, filename) for filename in group_files] + upload_meta = meta.copy() + console.print(f"[cyan]Uploading comparison group {second} with files: {group_files}") + + upload_result, _ = await upload_screens( + upload_meta, custom_img_list, img_host_num, 0, len(custom_img_list), custom_img_list, {} + ) + + uploaded_infos = [ + {k: item.get(k) for k in ("img_url", "raw_url", "web_url")} + for item in upload_result + ] + + group_name = suffixes.get(second, "") + + meta_comparisons[second] = { + "files": group_files, + "urls": uploaded_infos, + "img_host": current_img_host, + "name": group_name + } + + comparison_index = meta.get('comparison_index') + if not comparison_index: + console.print("[red]No comparison index provided. Please specify a comparison index matching the input file.") + while True: + cli_input = cli_ui.input("Enter comparison index number: ") + try: + comparison_index = str(int(cli_input.strip())) + break + except Exception: + console.print(f"[red]Invalid comparison index: {cli_input.strip()}") + if comparison_index and comparison_index in meta_comparisons: + if 'image_list' not in meta: + meta['image_list'] = [] + + urls_to_add = meta_comparisons[comparison_index].get('urls', []) + if meta.get('debug'): + console.print(f"[cyan]Adding {len(urls_to_add)} images from comparison group {comparison_index} to image_list") + + for url_info in urls_to_add: + if url_info not in meta['image_list']: + meta['image_list'].append(url_info) + + meta["comparison_groups"] = meta_comparisons + + try: + with open(comparison_data_file, 'w') as f: + json.dump(meta_comparisons, f, indent=4) + if meta.get('debug'): + console.print(f"[cyan]Saved comparison data to {comparison_data_file}") + except Exception as e: + console.print(f"[yellow]Failed to save comparison data: {e}") + + return meta_comparisons diff --git a/src/apply_overrides.py b/src/apply_overrides.py new file mode 100644 index 000000000..872d18c65 --- /dev/null +++ b/src/apply_overrides.py @@ -0,0 +1,191 @@ +import json +import traceback +from src.console import console +from src.args import Args +from data.config import config + + +async def get_source_override(meta, other_id=False): + try: + with open(f"{meta['base_dir']}/data/templates/user-args.json", 'r', encoding="utf-8") as f: + console.print("[green]Found user-args.json") + user_args = json.load(f) + + current_tmdb_id = meta.get('tmdb_id', 0) + current_imdb_id = meta.get('imdb_id', 0) + current_tvdb_id = meta.get('tvdb_id', 0) + + # Convert to int for comparison if it's a string + if isinstance(current_tmdb_id, str) and current_tmdb_id.isdigit(): + current_tmdb_id = int(current_tmdb_id) + + if isinstance(current_imdb_id, str) and current_imdb_id.isdigit(): + current_imdb_id = int(current_imdb_id) + + if isinstance(current_tvdb_id, str) and current_tvdb_id.isdigit(): + current_tvdb_id = int(current_tvdb_id) + + if not other_id: + for entry in user_args.get('entries', []): + entry_tmdb_id = entry.get('tmdb_id') + args = entry.get('args', []) + + if not entry_tmdb_id: + continue + + # Parse the entry's TMDB ID from the user-args.json file + entry_category, entry_normalized_id = await parse_tmdb_id(entry_tmdb_id) + if entry_category != meta['category']: + if meta['debug']: + console.print(f"Skipping user entry because override category {entry_category} does not match UA category {meta['category']}:") + continue + + # Check if IDs match + if entry_normalized_id == current_tmdb_id: + console.print(f"[green]Found matching override for TMDb ID: {entry_normalized_id}") + console.print(f"[yellow]Applying arguments: {' '.join(args)}") + + meta = await apply_args_to_meta(meta, args) + break + + else: + for entry in user_args.get('other_ids', []): + # Check for TVDB ID match + if 'tvdb_id' in entry and str(entry['tvdb_id']) == str(current_tvdb_id) and current_tvdb_id != 0: + args = entry.get('args', []) + console.print(f"[green]Found matching override for TVDb ID: {current_tvdb_id}") + console.print(f"[yellow]Applying arguments: {' '.join(args)}") + meta = await apply_args_to_meta(meta, args) + break + + # Check for IMDB ID match (without tt prefix) + if 'imdb_id' in entry: + entry_imdb = entry['imdb_id'] + if str(entry_imdb).startswith('tt'): + entry_imdb = entry_imdb[2:] + + if str(entry_imdb) == str(current_imdb_id) and current_imdb_id != 0: + args = entry.get('args', []) + console.print(f"[green]Found matching override for IMDb ID: {current_imdb_id}") + console.print(f"[yellow]Applying arguments: {' '.join(args)}") + meta = await apply_args_to_meta(meta, args) + break + + except (FileNotFoundError, json.JSONDecodeError) as e: + console.print(f"[red]Error loading user-args.json: {e}") + + return meta + + +async def parse_tmdb_id(tmdb_id, category=None): + if tmdb_id is None: + return category, 0 + + tmdb_id = str(tmdb_id).strip().lower() + if not tmdb_id or tmdb_id == 0: + return category, 0 + + if '/' in tmdb_id: + parts = tmdb_id.split('/') + if len(parts) >= 2: + prefix = parts[0] + id_part = parts[1] + + if prefix == 'tv': + category = 'TV' + elif prefix == 'movie': + category = 'MOVIE' + + try: + normalized_id = int(id_part) + return category, normalized_id + except ValueError: + return category, 0 + + try: + normalized_id = int(tmdb_id) + return category, normalized_id + except ValueError: + return category, 0 + + +async def apply_args_to_meta(meta, args): + try: + arg_keys_to_track = set() + arg_values = {} + + i = 0 + while i < len(args): + arg = args[i] + if arg.startswith('--'): + # Remove '--' prefix and convert dashes to underscores + key = arg[2:].replace('-', '_') + arg_keys_to_track.add(key) + + # Store the value if it exists + if i + 1 < len(args) and not args[i + 1].startswith('--'): + arg_values[key] = args[i + 1] # Store the value with its key + i += 1 + i += 1 + + if meta['debug']: + console.print(f"[Debug] Tracking changes for keys: {', '.join(arg_keys_to_track)}") + + # Create a new Args instance and process the arguments + arg_processor = Args(config) + full_args = ['upload.py'] + args + updated_meta, _, _ = arg_processor.parse(full_args, meta.copy()) + updated_meta['path'] = meta.get('path') + modified_keys = [] + + # Handle ID arguments specifically + id_mappings = { + 'tmdb': ['tmdb_id', 'tmdb', 'tmdb_manual'], + 'tvmaze': ['tvmaze_id', 'tvmaze', 'tvmaze_manual'], + 'imdb': ['imdb_id', 'imdb', 'imdb_manual'], + 'tvdb': ['tvdb_id', 'tvdb', 'tvdb_manual'], + } + + for key in arg_keys_to_track: + # Special handling for ID fields + if key in id_mappings: + if key in arg_values: # Check if we have a value for this key + value = arg_values[key] + # Convert to int if possible + try: + if isinstance(value, str) and value.isdigit(): + value = int(value) + elif isinstance(value, str) and key == 'imdb' and value.startswith('tt'): + value = int(value[2:]) # Remove 'tt' prefix and convert to int + except ValueError: + pass + + # Update all related keys + for related_key in id_mappings[key]: + meta[related_key] = value + modified_keys.append(related_key) + if meta['debug']: + console.print(f"[Debug] Override: {related_key} changed from {meta.get(related_key)} to {value}") + # Handle regular fields + elif key in updated_meta and key in meta: + # Skip path to preserve original + if key == 'path': + continue + + new_value = updated_meta[key] + old_value = meta[key] + # Only update if the value actually changed + if new_value != old_value: + meta[key] = new_value + modified_keys.append(key) + if meta['debug']: + console.print(f"[Debug] Override: {key} changed from {old_value} to {new_value}") + if meta['debug'] and modified_keys: + console.print(f"[Debug] Applied overrides for: {', '.join(modified_keys)}") + + except Exception as e: + console.print(f"[red]Error processing arguments: {e}") + if meta['debug']: + console.print(traceback.format_exc()) + + return meta diff --git a/src/args.py b/src/args.py index ff93c8e0e..a4f794533 100644 --- a/src/args.py +++ b/src/args.py @@ -3,88 +3,202 @@ import urllib.parse import os import datetime -import traceback - +import sys +import re from src.console import console +class ShortHelpFormatter(argparse.HelpFormatter): + """ + Custom formatter for short help (-h) + Only displays essential options. + """ + + def __init__(self, prog): + super().__init__(prog, max_help_position=40, width=80) + + def format_help(self): + """ + Customize short help output (only show essential arguments). + """ + short_usage = "usage: upload.py [path...] [options]\n\n" + short_options = """ +Common options: + -tmdb, --tmdb Specify the TMDb id to use with movie/ or tv/ prefix + -imdb, --imdb Specify the IMDb id to use + -tvmaze, --tvmaze Specify the TVMaze id to use + -tvdb, --tvdb Specify the TVDB id to use + --queue (queue name) Process an entire folder (including files/subfolders) in a queue + -mf, --manual_frames Comma-seperated list of frame numbers to use for screenshots + -df, --descfile Path to custom description file + -serv, --service Streaming service + --no-aka Remove AKA from title + -daily, --daily Air date of a daily type episode (YYYY-MM-DD) + -c, --category Category (movie, tv, fanres) + -t, --type Type (disc, remux, encode, webdl, etc.) + --source Source (Blu-ray, BluRay, DVD, WEBDL, etc.) + -comps, --comparison Use comparison images from a folder (input folder path): see -comps_index + -debug, --debug Prints more information, runs everything without actually uploading + +Use --help for a full list of options. +""" + return short_usage + short_options + + +class CustomArgumentParser(argparse.ArgumentParser): + """ + Custom ArgumentParser to handle short (-h) and long (--help) help messages. + """ + + def print_help(self, file=None): + """ + Show short help for `-h` and full help for `--help` + """ + if "--help" in sys.argv: + super().print_help(file) # Full help + else: + short_parser = argparse.ArgumentParser( + formatter_class=ShortHelpFormatter, add_help=False, usage="upload.py [path...] [options]" + ) + short_parser.print_help(file) + + class Args(): """ Parse Args """ + def __init__(self, config): self.config = config pass - - def parse(self, args, meta): input = args - parser = argparse.ArgumentParser() - - parser.add_argument('path', nargs='*', help="Path to file/directory") - parser.add_argument('-s', '--screens', nargs='*', required=False, help="Number of screenshots", default=int(self.config['DEFAULT']['screens'])) - parser.add_argument('-c', '--category', nargs='*', required=False, help="Category [MOVIE, TV, FANRES]", choices=['movie', 'tv', 'fanres']) - parser.add_argument('-t', '--type', nargs='*', required=False, help="Type [DISC, REMUX, ENCODE, WEBDL, WEBRIP, HDTV]", choices=['disc', 'remux', 'encode', 'webdl', 'web-dl', 'webrip', 'hdtv']) - parser.add_argument('--source', nargs='*', required=False, help="Source [Blu-ray, BluRay, DVD, HDDVD, WEB, HDTV, UHDTV]", choices=['Blu-ray', 'BluRay', 'DVD', 'HDDVD', 'WEB', 'HDTV', 'UHDTV'], dest="manual_source") - parser.add_argument('-res', '--resolution', nargs='*', required=False, help="Resolution [2160p, 1080p, 1080i, 720p, 576p, 576i, 480p, 480i, 8640p, 4320p, OTHER]", choices=['2160p', '1080p', '1080i', '720p', '576p', '576i', '480p', '480i', '8640p', '4320p', 'other']) - parser.add_argument('-tmdb', '--tmdb', nargs='*', required=False, help="TMDb ID", type=str, dest='tmdb_manual') - parser.add_argument('-imdb', '--imdb', nargs='*', required=False, help="IMDb ID", type=str) - parser.add_argument('-mal', '--mal', nargs='*', required=False, help="MAL ID", type=str) + parser = CustomArgumentParser( + usage="upload.py [path...] [options]", + ) + + parser.add_argument('path', nargs='+', help="Path to file/directory (in single/double quotes is best)") + parser.add_argument('--queue', nargs=1, required=False, help="(--queue queue_name) Process an entire folder (files/subfolders) in a queue") + parser.add_argument('-lq', '--limit-queue', dest='limit_queue', nargs=1, required=False, help="Limit the amount of queue files processed", type=int, default=0) + parser.add_argument('--unit3d', action='store_true', required=False, help="[parse a txt output file from UNIT3D-Upload-Checker]") + parser.add_argument('-s', '--screens', nargs=1, required=False, help="Number of screenshots", default=int(self.config['DEFAULT']['screens'])) + parser.add_argument('-comps', '--comparison', nargs='+', required=False, help="Use comparison images from a folder (input folder path). See: https://github.com/Audionut/Upload-Assistant/pull/487", default=None) + parser.add_argument('-comps_index', '--comparison_index', nargs=1, required=False, help="Which of your comparison indexes is the main images (required when comps)", type=int, default=None) + parser.add_argument('-mf', '--manual_frames', nargs=1, required=False, help="Comma-separated frame numbers to use as screenshots", type=str, default=None) + parser.add_argument('-c', '--category', nargs=1, required=False, help="Category [movie, tv, fanres]", choices=['movie', 'tv', 'fanres'], dest="manual_category") + parser.add_argument('-t', '--type', nargs=1, required=False, help="Type [DISC, REMUX, ENCODE, WEBDL, WEBRIP, HDTV, DVDRIP]", choices=['disc', 'remux', 'encode', 'webdl', 'web-dl', 'webrip', 'hdtv', 'dvdrip'], dest="manual_type") + parser.add_argument('--source', nargs=1, required=False, help="Source [Blu-ray, BluRay, DVD, DVD5, DVD9, HDDVD, WEB, HDTV, UHDTV, LaserDisc, DCP]", choices=['Blu-ray', 'BluRay', 'DVD', 'DVD5', 'DVD9', 'HDDVD', 'WEB', 'HDTV', 'UHDTV', 'LaserDisc', 'DCP'], dest="manual_source") + parser.add_argument('-res', '--resolution', nargs=1, required=False, help="Resolution [2160p, 1080p, 1080i, 720p, 576p, 576i, 480p, 480i, 8640p, 4320p, OTHER]", choices=['2160p', '1080p', '1080i', '720p', '576p', '576i', '480p', '480i', '8640p', '4320p', 'other']) + parser.add_argument('-tmdb', '--tmdb', nargs=1, required=False, help="TMDb ID (use movie/ or tv/ prefix)", type=str, dest='tmdb_manual') + parser.add_argument('-imdb', '--imdb', nargs=1, required=False, help="IMDb ID", type=str, dest='imdb_manual') + parser.add_argument('-mal', '--mal', nargs=1, required=False, help="MAL ID", type=str, dest='mal_manual') + parser.add_argument('-tvmaze', '--tvmaze', nargs=1, required=False, help="TVMAZE ID", type=str, dest='tvmaze_manual') + parser.add_argument('-tvdb', '--tvdb', nargs=1, required=False, help="TVDB ID", type=str, dest='tvdb_manual') parser.add_argument('-g', '--tag', nargs='*', required=False, help="Group Tag", type=str) parser.add_argument('-serv', '--service', nargs='*', required=False, help="Streaming Service", type=str) parser.add_argument('-dist', '--distributor', nargs='*', required=False, help="Disc Distributor e.g.(Criterion, BFI, etc.)", type=str) - parser.add_argument('-edition', '--edition', '--repack', nargs='*', required=False, help="Edition/Repack String e.g.(Director's Cut, Uncut, Hybrid, REPACK, REPACK3)", type=str, dest='manual_edition', default="") - parser.add_argument('-season', '--season', nargs='*', required=False, help="Season (number)", type=str) - parser.add_argument('-episode', '--episode', nargs='*', required=False, help="Episode (number)", type=str) + parser.add_argument('-edition', '--edition', '--repack', nargs='*', required=False, help="Edition/Repack String e.g.(Director's Cut, Uncut, Hybrid, REPACK, REPACK3)", type=str, dest='manual_edition') + parser.add_argument('-season', '--season', nargs=1, required=False, help="Season (number)", type=str) + parser.add_argument('-episode', '--episode', nargs=1, required=False, help="Episode (number)", type=str) + parser.add_argument('--not-anime', dest='not_anime', action='store_true', required=False, help="This is not an Anime release") + parser.add_argument('-met', '--manual-episode-title', nargs='*', required=False, help="Set episode title, empty = empty", type=str, dest="manual_episode_title", default=None) parser.add_argument('-daily', '--daily', nargs=1, required=False, help="Air date of this episode (YYYY-MM-DD)", type=datetime.date.fromisoformat, dest="manual_date") parser.add_argument('--no-season', dest='no_season', action='store_true', required=False, help="Remove Season from title") parser.add_argument('--no-year', dest='no_year', action='store_true', required=False, help="Remove Year from title") parser.add_argument('--no-aka', dest='no_aka', action='store_true', required=False, help="Remove AKA from title") parser.add_argument('--no-dub', dest='no_dub', action='store_true', required=False, help="Remove Dubbed from title") + parser.add_argument('--no-dual', dest='no_dual', action='store_true', required=False, help="Remove Dual-Audio from title") parser.add_argument('--no-tag', dest='no_tag', action='store_true', required=False, help="Remove Group Tag from title") + parser.add_argument('--no-edition', dest='no_edition', action='store_true', required=False, help="Remove Edition from title") + parser.add_argument('--dual-audio', dest='dual_audio', action='store_true', required=False, help="Add Dual-Audio to the title") + parser.add_argument('-ol', '--original-language', dest='manual_language', nargs=1, required=False, help="Set original audio language") + parser.add_argument('-oil', '--only-if-languages', dest='has_languages', nargs='*', required=False, help="Require at least one of the languages to upload. Comma separated list e.g. 'English, French, Spanish'", type=str) parser.add_argument('-ns', '--no-seed', action='store_true', required=False, help="Do not add torrent to the client") - parser.add_argument('-year', '--year', dest='manual_year', nargs='?', required=False, help="Year", type=int, default=0) - parser.add_argument('-ptp', '--ptp', nargs='*', required=False, help="PTP torrent id/permalink", type=str) - parser.add_argument('-blu', '--blu', nargs='*', required=False, help="BLU torrent id/link", type=str) - parser.add_argument('-hdb', '--hdb', nargs='*', required=False, help="HDB torrent id/link", type=str) - parser.add_argument('-d', '--desc', nargs='*', required=False, help="Custom Description (string)") - parser.add_argument('-pb', '--desclink', nargs='*', required=False, help="Custom Description (link to hastebin/pastebin)") - parser.add_argument('-df', '--descfile', nargs='*', required=False, help="Custom Description (path to file)") - parser.add_argument('-ih', '--imghost', nargs='*', required=False, help="Image Host", choices=['imgbb', 'ptpimg', 'imgbox', 'pixhost', 'lensdump']) + parser.add_argument('-year', '--year', dest='manual_year', nargs=1, required=False, help="Override the year found", type=int, default=0) + parser.add_argument('-mc', '--commentary', dest='manual_commentary', action='store_true', required=False, help="Manually indicate whether commentary tracks are included") + parser.add_argument('-sfxs', '--sfx-subtitles', dest='sfx_subtitles', action='store_true', required=False, help="Manually indicate whether subtitles with visual enhancements like animations, effects, or backgrounds are included") + parser.add_argument('-e', '--extras', dest='extras', action='store_true', required=False, help="Indicates that extras are included. Mainly used for Blu-rays discs") + parser.add_argument('-sort', '--sorted-filelist', dest='sorted_filelist', action='store_true', required=False, help="Use the largest video file for processing instead of the first video file found") + parser.add_argument('-ptp', '--ptp', nargs=1, required=False, help="PTP torrent id/permalink", type=str) + parser.add_argument('-blu', '--blu', nargs=1, required=False, help="BLU torrent id/link", type=str) + parser.add_argument('-aither', '--aither', nargs=1, required=False, help="Aither torrent id/link", type=str) + parser.add_argument('-lst', '--lst', nargs=1, required=False, help="LST torrent id/link", type=str) + parser.add_argument('-oe', '--oe', nargs=1, required=False, help="OE torrent id/link", type=str) + parser.add_argument('-tik', '--tik', nargs=1, required=False, help="TIK torrent id/link", type=str) + parser.add_argument('-hdb', '--hdb', nargs=1, required=False, help="HDB torrent id/link", type=str) + parser.add_argument('-btn', '--btn', nargs=1, required=False, help="BTN torrent id/link", type=str) + parser.add_argument('-bhd', '--bhd', nargs=1, required=False, help="BHD torrent_id/link", type=str) + parser.add_argument('-huno', '--huno', nargs=1, required=False, help="HUNO torrent id/link", type=str) + parser.add_argument('-ulcx', '--ulcx', nargs=1, required=False, help="ULCX torrent id/link", type=str) + parser.add_argument('-req', '--search_requests', action='store_true', required=False, help="Search for matching requests on supported trackers", default=None) + parser.add_argument('-sat', '--skip_auto_torrent', action='store_true', required=False, help="Skip automated qbittorrent client torrent searching", default=None) + parser.add_argument('-onlyID', '--onlyID', action='store_true', required=False, help="Only grab meta ids (tmdb/imdb/etc) from tracker, not description/image links.", default=None) + parser.add_argument('--foreign', dest='foreign', action='store_true', required=False, help="Set for TIK Foreign category") + parser.add_argument('--opera', dest='opera', action='store_true', required=False, help="Set for TIK Opera & Musical category") + parser.add_argument('--asian', dest='asian', action='store_true', required=False, help="Set for TIK Asian category") + parser.add_argument('-disctype', '--disctype', nargs=1, required=False, help="Type of disc for TIK (BD100, BD66, BD50, BD25, NTSC DVD9, NTSC DVD5, PAL DVD9, PAL DVD5, Custom, 3D)", type=str) + parser.add_argument('--untouched', dest='untouched', action='store_true', required=False, help="Set when a completely untouched disc at TIK") + parser.add_argument('-manual_dvds', '--manual_dvds', nargs=1, required=False, help="Override the default number of DVD's (eg: use 2xDVD9+DVD5 instead)", type=str, dest='manual_dvds', default="") + parser.add_argument('-pb', '--desclink', nargs=1, required=False, help="Custom Description (link to hastebin/pastebin)") + parser.add_argument('-df', '--descfile', nargs=1, required=False, help="Custom Description (path to file OR filename in current working directory)") + parser.add_argument('-ih', '--imghost', nargs=1, required=False, help="Image Host", choices=['imgbb', 'ptpimg', 'imgbox', 'pixhost', 'lensdump', 'ptscreens', 'onlyimage', 'dalexni', 'zipline', 'passtheimage']) parser.add_argument('-siu', '--skip-imagehost-upload', dest='skip_imghost_upload', action='store_true', required=False, help="Skip Uploading to an image host") - parser.add_argument('-th', '--torrenthash', nargs='*', required=False, help="Torrent Hash to re-use from your client's session directory") + parser.add_argument('-th', '--torrenthash', nargs=1, required=False, help="Torrent Hash to re-use from your client's session directory") parser.add_argument('-nfo', '--nfo', action='store_true', required=False, help="Use .nfo in directory for description") - parser.add_argument('-k', '--keywords', nargs='*', required=False, help="Add comma seperated keywords e.g. 'keyword, keyword2, etc'") - parser.add_argument('-reg', '--region', nargs='*', required=False, help="Region for discs") + parser.add_argument('-k', '--keywords', nargs=1, required=False, help="Add comma separated keywords e.g. 'keyword, keyword2, etc'") + parser.add_argument('-kf', '--keep-folder', action='store_true', required=False, help="Keep the folder containing the single file. Works only when supplying a directory as input. For uploads with poor filenames, like some scene.") + parser.add_argument('-reg', '--region', nargs=1, required=False, help="Region for discs") parser.add_argument('-a', '--anon', action='store_true', required=False, help="Upload anonymously") parser.add_argument('-st', '--stream', action='store_true', required=False, help="Stream Optimized Upload") - parser.add_argument('-webdv', '--webdv', action='store_true', required=False, help="Contains a Dolby Vision layer converted using dovi_tool") + parser.add_argument('-webdv', '--webdv', action='store_true', required=False, help="Contains a Dolby Vision layer converted using dovi_tool (HYBRID)") parser.add_argument('-hc', '--hardcoded-subs', action='store_true', required=False, help="Contains hardcoded subs", dest="hardcoded-subs") parser.add_argument('-pr', '--personalrelease', action='store_true', required=False, help="Personal Release") - parser.add_argument('-sdc','--skip-dupe-check', action='store_true', required=False, help="Pass if you know this is a dupe (Skips dupe check)", dest="dupe") + parser.add_argument('-sdc', '--skip-dupe-check', action='store_true', required=False, help="Ignore dupes and upload anyway (Skips dupe check)", dest="dupe") + parser.add_argument('-sda', '--skip-dupe-asking', action='store_true', required=False, help="Don't prompt about dupes, just treat dupes as actual dupes", dest="ask_dupe") parser.add_argument('-debug', '--debug', action='store_true', required=False, help="Debug Mode, will run through all the motions providing extra info, but will not upload to trackers.") parser.add_argument('-ffdebug', '--ffdebug', action='store_true', required=False, help="Will show info from ffmpeg while taking screenshots.") - parser.add_argument('-m', '--manual', action='store_true', required=False, help="Manual Mode. Returns link to ddl screens/base.torrent") + parser.add_argument('-mps', '--max-piece-size', nargs=1, required=False, help="Set max piece size allowed in MiB for default torrent creation (default 128 MiB)", choices=['2', '4', '8', '16', '32', '64', '128']) parser.add_argument('-nh', '--nohash', action='store_true', required=False, help="Don't hash .torrent") parser.add_argument('-rh', '--rehash', action='store_true', required=False, help="DO hash .torrent") - parser.add_argument('-ps', '--piece-size-max', dest='piece_size_max', nargs='*', required=False, help="Maximum piece size in MiB", choices=[1, 2, 4, 8, 16], type=int) - parser.add_argument('-dr', '--draft', action='store_true', required=False, help="Send to drafts (BHD)") - parser.add_argument('-tc', '--torrent-creation', dest='torrent_creation', nargs='*', required=False, help="What tool should be used to create the base .torrent", choices=['torf', 'torrenttools', 'mktorrent']) - parser.add_argument('-client', '--client', nargs='*', required=False, help="Use this torrent client instead of default") - parser.add_argument('-qbt', '--qbit-tag', dest='qbit_tag', nargs='*', required=False, help="Add to qbit with this tag") - parser.add_argument('-qbc', '--qbit-cat', dest='qbit_cat', nargs='*', required=False, help="Add to qbit with this category") - parser.add_argument('-rtl', '--rtorrent-label', dest='rtorrent_label', nargs='*', required=False, help="Add to rtorrent with this label") - parser.add_argument('-tk', '--trackers', nargs='*', required=False, help="Upload to these trackers, space seperated (--trackers blu bhd)") - parser.add_argument('-rt', '--randomized', nargs='*', required=False, help="Number of extra, torrents with random infohash", default=0) + parser.add_argument('-mkbrr', '--mkbrr', action='store_true', required=False, help="Use mkbrr for torrent hashing") + parser.add_argument('-dr', '--draft', action='store_true', required=False, help="Send to drafts (BHD, LST)") + parser.add_argument('-mq', '--modq', action='store_true', required=False, help="Send to modQ") + parser.add_argument('-client', '--client', nargs=1, required=False, help="Use this torrent client instead of default") + parser.add_argument('-qbt', '--qbit-tag', dest='qbit_tag', nargs=1, required=False, help="Add to qbit with this tag") + parser.add_argument('-qbc', '--qbit-cat', dest='qbit_cat', nargs=1, required=False, help="Add to qbit with this category") + parser.add_argument('-rtl', '--rtorrent-label', dest='rtorrent_label', nargs=1, required=False, help="Add to rtorrent with this label") + parser.add_argument('-tk', '--trackers', nargs=1, required=False, help="Upload to these trackers, comma separated (--trackers blu,bhd) including manual") + parser.add_argument('-rtk', '--trackers-remove', dest='trackers_remove', nargs=1, required=False, help="Remove these trackers when processing default trackers, comma separated (--trackers-remove blu,bhd)") + parser.add_argument('-tpc', '--trackers-pass', dest='trackers_pass', nargs=1, required=False, help="How many trackers need to pass all checks (dupe/banned group/etc) to actually proceed to uploading", type=int) + parser.add_argument('-rt', '--randomized', nargs=1, required=False, help="Number of extra, torrents with random infohash", default=0) + parser.add_argument('-entropy', '--entropy', dest='entropy', nargs=1, required=False, help="Use entropy in created torrents. (32 or 64) bits (ie: -entropy 32). Not supported at all sites, you many need to redownload the torrent", type=int, default=0) parser.add_argument('-ua', '--unattended', action='store_true', required=False, help=argparse.SUPPRESS) + parser.add_argument('-uac', '--unattended_confirm', action='store_true', required=False, help=argparse.SUPPRESS) parser.add_argument('-vs', '--vapoursynth', action='store_true', required=False, help="Use vapoursynth for screens (requires vs install)") + parser.add_argument('-dm', '--delete-meta', action='store_true', required=False, dest='delete_meta', help="Delete only meta.json from tmp directory") + parser.add_argument('-dtmp', '--delete-tmp', action='store_true', required=False, dest='delete_tmp', help="Delete tmp directory for the working file/folder") parser.add_argument('-cleanup', '--cleanup', action='store_true', required=False, help="Clean up tmp directory") - - parser.add_argument('-fl', '--freeleech', nargs='*', required=False, help="Freeleech Percentage", default=0, dest="freeleech") + parser.add_argument('-fl', '--freeleech', nargs=1, required=False, help="Freeleech Percentage. Any value 1-100 works, but site search is limited to certain values", default=0, dest="freeleech") + parser.add_argument('--infohash', nargs=1, required=False, help="V1 Info Hash") + parser.add_argument('-emby', '--emby', action='store_true', required=False, help="Create an Emby-compliant NFO file and optionally symlink the content") + parser.add_argument('-emby_cat', '--emby_cat', nargs=1, required=False, help="Set the expected category for Emby (e.g., 'movie', 'tv')") + parser.add_argument('-emby_debug', '--emby_debug', action='store_true', required=False, help="Does debugging stuff for Audionut") + parser.add_argument('-ch', '--channel', nargs=1, required=False, help="SPD only: Channel ID number or tag to upload to (preferably the ID), without '@'. Example: '-ch spd' when using a tag, or '-ch 1' when using an ID.", type=str, dest='spd_channel', default="") args, before_args = parser.parse_known_args(input) args = vars(args) # console.print(args) + if meta.get('manual_frames') is not None: + try: + # Join the list into a single string, split by commas, and convert to integers + meta['manual_frames'] = [int(time.strip()) for time in meta['manual_frames'].split(',')] + # console.print(f"Processed manual_frames: {meta['manual_frames']}") + except ValueError: + console.print("[red]Invalid format for manual_frames. Please provide a comma-separated list of integers.") + console.print(f"Processed manual_frames: {meta['manual_frames']}") + sys.exit(1) + else: + meta['manual_frames'] = None # Explicitly set it to None if not provided if len(before_args) >= 1 and not os.path.exists(' '.join(args['path'])): for each in before_args: args['path'].append(each) @@ -94,18 +208,22 @@ def parse(self, args, meta): break else: break - - if meta.get('tmdb_manual') != None or meta.get('imdb') != None: - meta['tmdb_manual'] = meta['imdb'] = None + + if meta.get('tmdb_manual') is not None or meta.get('imdb_manual') is not None: + meta['tmdb_manual'] = meta['tmdb_id'] = meta['tmdb'] = meta['imdb_id'] = meta['imdb'] = None for key in args: value = args.get(key) if value not in (None, []): if isinstance(value, list): value2 = self.list_to_string(value) - if key == 'type': - meta[key] = value2.upper().replace('-','') + if key == 'manual_type': + meta['manual_type'] = value2.upper().replace('-', '') elif key == 'tag': meta[key] = f"-{value2}" + elif key == 'descfile': + meta[key] = os.path.abspath(value2) + elif key == 'comparison': + meta[key] = os.path.abspath(value2) elif key == 'screens': meta[key] = int(value2) elif key == 'season': @@ -121,7 +239,7 @@ def parse(self, args, meta): parsed = urllib.parse.urlparse(value2) try: meta['ptp'] = urllib.parse.parse_qs(parsed.query)['torrentid'][0] - except: + except Exception: console.print('[red]Your terminal ate part of the url, please surround in quotes next time, or pass only the torrentid') console.print('[red]Continuing without -ptp') else: @@ -134,53 +252,190 @@ def parse(self, args, meta): if blupath.endswith('/'): blupath = blupath[:-1] meta['blu'] = blupath.split('/')[-1] - except: + except Exception: console.print('[red]Unable to parse id from url') console.print('[red]Continuing without --blu') else: meta['blu'] = value2 + elif key == 'aither': + if value2.startswith('http'): + parsed = urllib.parse.urlparse(value2) + try: + aitherpath = parsed.path + if aitherpath.endswith('/'): + aitherpath = aitherpath[:-1] + meta['aither'] = aitherpath.split('/')[-1] + except Exception: + console.print('[red]Unable to parse id from url') + console.print('[red]Continuing without --aither') + else: + meta['aither'] = value2 + elif key == 'lst': + if value2.startswith('http'): + parsed = urllib.parse.urlparse(value2) + try: + lstpath = parsed.path + if lstpath.endswith('/'): + lstpath = lstpath[:-1] + meta['lst'] = lstpath.split('/')[-1] + except Exception: + console.print('[red]Unable to parse id from url') + console.print('[red]Continuing without --lst') + else: + meta['lst'] = value2 + elif key == 'oe': + if value2.startswith('http'): + parsed = urllib.parse.urlparse(value2) + try: + oepath = parsed.path + if oepath.endswith('/'): + oepath = oepath[:-1] + meta['oe'] = oepath.split('/')[-1] + except Exception: + console.print('[red]Unable to parse id from url') + console.print('[red]Continuing without --oe') + else: + meta['oe'] = value2 + elif key == 'ulcx': + if value2.startswith('http'): + parsed = urllib.parse.urlparse(value2) + try: + ulcxpath = parsed.path + if ulcxpath.endswith('/'): + ulcxpath = ulcxpath[:-1] + meta['ulcx'] = ulcxpath.split('/')[-1] + except Exception: + console.print('[red]Unable to parse id from url') + console.print('[red]Continuing without --ulcx') + else: + meta['ulcx'] = value2 elif key == 'hdb': if value2.startswith('http'): parsed = urllib.parse.urlparse(value2) try: meta['hdb'] = urllib.parse.parse_qs(parsed.query)['id'][0] - except: + except Exception: console.print('[red]Your terminal ate part of the url, please surround in quotes next time, or pass only the torrentid') console.print('[red]Continuing without -hdb') else: meta['hdb'] = value2 + elif key == 'btn': + if value2.startswith('http'): + parsed = urllib.parse.urlparse(value2) + try: + meta['btn'] = urllib.parse.parse_qs(parsed.query)['id'][0] + except Exception: + console.print('[red]Your terminal ate part of the url, please surround in quotes next time, or pass only the torrentid') + console.print('[red]Continuing without -hdb') + else: + meta['btn'] = value2 + + elif key == 'bhd': + if value2.startswith('http'): + parsed = urllib.parse.urlparse(value2) + try: + bhdpath = parsed.path + if bhdpath.endswith('/'): + bhdpath = bhdpath[:-1] + + if '/download/' in bhdpath or '/torrents/' in bhdpath: + torrent_id_match = re.search(r'\.(\d+)$', bhdpath) + if torrent_id_match: + meta['bhd'] = torrent_id_match.group(1) + else: + meta['bhd'] = bhdpath.split('/')[-1] + else: + meta['bhd'] = bhdpath.split('/')[-1] + + console.print(f"[green]Parsed BHD torrent ID: {meta['bhd']}") + except Exception as e: + console.print(f'[red]Unable to parse id from url: {e}') + console.print('[red]Continuing without --bhd') + else: + meta['bhd'] = value2 + + elif key == 'huno': + if value2.startswith('http'): + parsed = urllib.parse.urlparse(value2) + try: + hunopath = parsed.path + if hunopath.endswith('/'): + hunopath = hunopath[:-1] + meta['huno'] = hunopath.split('/')[-1] + except Exception: + console.print('[red]Unable to parse id from url') + console.print('[red]Continuing without --huno') + else: + meta['huno'] = value2 + else: meta[key] = value2 else: meta[key] = value elif key in ("manual_edition"): + if isinstance(value, list) and len(value) == 1: + meta[key] = value[0] + else: + meta[key] = value + elif key in ("manual_dvds"): meta[key] = value elif key in ("freeleech"): meta[key] = 100 elif key in ("tag") and value == []: meta[key] = "" + elif key in ["manual_episode_title"] and value == []: + meta[key] = "" + elif key in ["manual_episode_title"]: + meta[key] = value + elif key in ["tvmaze_manual"]: + meta[key] = value + elif key == 'trackers': + if value: + tracker_value = value + if isinstance(tracker_value, str): + tracker_value = tracker_value.strip('"\'') + + if isinstance(tracker_value, str) and ',' in tracker_value: + meta[key] = [t.strip().upper() for t in tracker_value.split(',')] + else: + meta[key] = [tracker_value.strip().upper()] if isinstance(tracker_value, str) else [tracker_value.upper()] + else: + meta[key] = [] else: meta[key] = meta.get(key, None) - if key in ('trackers'): - meta[key] = value # if key == 'help' and value == True: # parser.print_help() return meta, parser, before_args - def list_to_string(self, list): if len(list) == 1: return str(list[0]) try: result = " ".join(list) - except: + except Exception: result = "None" return result - def parse_tmdb_id(self, id, category): - id = id.lower().lstrip() + id = str(id).lower().strip() + if id.startswith('http'): + parsed = urllib.parse.urlparse(id) + path = parsed.path.strip('/') + + if '/' in path: + parts = path.split('/') + if len(parts) >= 2: + type_part = parts[-2] + id_part = parts[-1] + + if type_part == 'tv': + category = 'TV' + elif type_part == 'movie': + category = 'MOVIE' + + id = id_part + if id.startswith('tv'): id = id.split('/')[1] category = 'TV' @@ -189,19 +444,10 @@ def parse_tmdb_id(self, id, category): category = 'MOVIE' else: id = id - return category, id - - - - - - - - - - - - - + if isinstance(id, str) and id.isdigit(): + id = int(id) + else: + id = 0 + return category, id diff --git a/src/audio.py b/src/audio.py new file mode 100644 index 000000000..2cf27a888 --- /dev/null +++ b/src/audio.py @@ -0,0 +1,235 @@ +import time +import traceback +from src.console import console + + +async def get_audio_v2(mi, meta, bdinfo): + extra = dual = "" + has_commentary = False + meta['bloated'] = False + + # Get formats + if bdinfo is not None: # Disks + format_settings = "" + format = bdinfo.get('audio', [{}])[0].get('codec', '') + commercial = format + additional = bdinfo.get('audio', [{}])[0].get('atmos_why_you_be_like_this', '') + + # Channels + chan = bdinfo.get('audio', [{}])[0].get('channels', '') + else: + tracks = mi.get('media', {}).get('track', []) + audio_tracks = [t for t in tracks if t.get('@type') == "Audio"] + first_audio_track = None + if audio_tracks: + tracks_with_order = [t for t in audio_tracks if t.get('StreamOrder')] + if tracks_with_order: + first_audio_track = min(tracks_with_order, key=lambda x: int(x.get('StreamOrder', '999'))) + else: + tracks_with_id = [t for t in audio_tracks if t.get('ID')] + if tracks_with_id: + first_audio_track = min(tracks_with_id, key=lambda x: int(x.get('ID', '999'))) + else: + first_audio_track = audio_tracks[0] + + track = first_audio_track if first_audio_track else {} + format = track.get('Format', '') + commercial = track.get('Format_Commercial', '') or track.get('Format_Commercial_IfAny', '') + + if track.get('Language', '') == "zxx": + meta['silent'] = True + + additional = track.get('Format_AdditionalFeatures', '') + + format_settings = track.get('Format_Settings', '') + if not isinstance(format_settings, str): + format_settings = "" + if format_settings in ['Explicit']: + format_settings = "" + format_profile = track.get('Format_Profile', '') + # Channels + channels = track.get('Channels_Original', track.get('Channels')) + if not str(channels).isnumeric(): + channels = track.get('Channels') + try: + channel_layout = track.get('ChannelLayout', '') or track.get('ChannelLayout_Original', '') or track.get('ChannelPositions', '') + except Exception: + channel_layout = '' + + if channel_layout and "LFE" in channel_layout: + chan = f"{int(channels) - 1}.1" + elif channel_layout == "": + if int(channels) <= 2: + chan = f"{int(channels)}.0" + else: + chan = f"{int(channels) - 1}.1" + else: + chan = f"{channels}.0" + + if meta.get('dual_audio', False): + dual = "Dual-Audio" + else: + # if not meta.get('original_language', '').startswith('en'): + eng, orig, non_en_non_commentary = False, False, False + orig_lang = meta.get('original_language', '').lower() + if meta['debug']: + console.print(f"DEBUG: Original Language: {orig_lang}") + try: + tracks = mi.get('media', {}).get('track', []) + has_commentary = False + has_compatibility = False + has_coms = [t for t in tracks if "commentary" in (t.get('Title') or '').lower()] + has_compat = [t for t in tracks if "compatibility" in (t.get('Title') or '').lower()] + if has_coms: + has_commentary = True + if has_compat: + has_compatibility = True + if meta['debug']: + console.print(f"DEBUG: Found {len(has_coms)} commentary tracks, has_commentary = {has_commentary}") + console.print(f"DEBUG: Found {len(has_compat)} compatibility tracks, has_compatibility = {has_compatibility}") + audio_tracks = [ + t for t in tracks + if t.get('@type') == "Audio" and not has_commentary and not has_compatibility + ] + audio_language = None + if meta['debug']: + console.print(f"DEBUG: Audio Tracks (not commentary)= {len(audio_tracks)}") + for t in audio_tracks: + audio_language = t.get('Language', '') + if meta['debug']: + console.print(f"DEBUG: Audio Language = {audio_language}") + + if isinstance(audio_language, str): + if audio_language.startswith("en"): + if meta['debug']: + console.print(f"DEBUG: Found English audio track: {audio_language}") + eng = True + + if audio_language and "en" not in audio_language and audio_language.startswith(orig_lang): + if meta['debug']: + console.print(f"DEBUG: Found original language audio track: {audio_language}") + orig = True + + variants = ['zh', 'cn', 'cmn', 'no', 'nb'] + if any(audio_language.startswith(var) for var in variants) and any(orig_lang.startswith(var) for var in variants): + if meta['debug']: + console.print(f"DEBUG: Found original language audio track with variant: {audio_language}") + orig = True + + if isinstance(audio_language, str): + audio_language = audio_language.strip().lower() + if audio_language and not audio_language.startswith(orig_lang) and not audio_language.startswith("en") and not audio_language.startswith("zx"): + non_en_non_commentary = True + console.print(f"[bold red]This release has a(n) {audio_language} audio track, and may be considered bloated") + time.sleep(5) + + if ( + orig_lang == "en" + and eng + and non_en_non_commentary + ): + console.print("[bold red]This release is English original, has English audio, but also has other non-English audio tracks (not commentary). This may be considered bloated.[/bold red]") + meta['bloated'] = True + time.sleep(5) + + if ((eng and (orig or non_en_non_commentary)) or (orig and non_en_non_commentary)) and len(audio_tracks) > 1 and not meta.get('no_dual', False): + dual = "Dual-Audio" + meta['dual_audio'] = True + elif eng and not orig and orig_lang not in ['zxx', 'xx', 'en', None] and not meta.get('no_dub', False): + dual = "Dubbed" + except Exception: + console.print(traceback.format_exc()) + pass + + # Convert commercial name to naming conventions + audio = { + "DTS": "DTS", + "AAC": "AAC", + "AAC LC": "AAC", + "AC-3": "DD", + "E-AC-3": "DD+", + "A_EAC3": "DD+", + "Enhanced AC-3": "DD+", + "MLP FBA": "TrueHD", + "FLAC": "FLAC", + "Opus": "Opus", + "Vorbis": "VORBIS", + "PCM": "LPCM", + "LPCM Audio": "LPCM", + "Dolby Digital Audio": "DD", + "Dolby Digital Plus Audio": "DD+", + "Dolby Digital Plus": "DD+", + "Dolby TrueHD Audio": "TrueHD", + "DTS Audio": "DTS", + "DTS-HD Master Audio": "DTS-HD MA", + "DTS-HD High-Res Audio": "DTS-HD HRA", + "DTS:X Master Audio": "DTS:X" + } + audio_extra = { + "XLL": "-HD MA", + "XLL X": ":X", + "ES": "-ES", + } + format_extra = { + "JOC": " Atmos", + "16-ch": " Atmos", + "Atmos Audio": " Atmos", + } + format_settings_extra = { + "Dolby Surround EX": "EX" + } + + commercial_names = { + "Dolby Digital": "DD", + "Dolby Digital Plus": "DD+", + "Dolby TrueHD": "TrueHD", + "DTS-ES": "DTS-ES", + "DTS-HD High": "DTS-HD HRA", + "Free Lossless Audio Codec": "FLAC", + "DTS-HD Master Audio": "DTS-HD MA" + } + + search_format = True + + if isinstance(additional, dict): + additional = "" # Set empty string if additional is a dictionary + + if commercial: + for key, value in commercial_names.items(): + if key in commercial: + codec = value + search_format = False + if "Atmos" in commercial or format_extra.get(additional, "") == " Atmos": + extra = " Atmos" + + if search_format: + codec = audio.get(format, "") + audio_extra.get(additional, "") + extra = format_extra.get(additional, "") + + format_settings = format_settings_extra.get(format_settings, "") + if format_settings == "EX" and chan == "5.1": + format_settings = "EX" + else: + format_settings = "" + + if codec == "": + codec = format + + if format.startswith("DTS"): + if additional and additional.endswith("X"): + codec = "DTS:X" + chan = f"{int(channels) - 1}.1" + + if format == "MPEG Audio": + if format_profile == "Layer 2": + codec = "MP2" + elif format_profile == "Layer 3": + codec = "MP3" + + if codec == "DD" and chan == "7.1": + console.print("[warning] Detected codec is DD but channel count is 7.1, correcting to DD+") + codec = "DD+" + + audio = f"{dual} {codec or ''} {format_settings or ''} {chan or ''}{extra or ''}" + audio = ' '.join(audio.split()) + return audio, chan, has_commentary diff --git a/src/bbcode.py b/src/bbcode.py index 8ddff33c8..4eee5f5e5 100644 --- a/src/bbcode.py +++ b/src/bbcode.py @@ -1,6 +1,8 @@ import re import html import urllib.parse +import os +from src.console import console # Bold - KEEP # Italic - KEEP @@ -35,64 +37,332 @@ class BBCODE: def __init__(self): pass + def clean_hdb_description(self, description): + # Unescape html + desc = html.unescape(description) + desc = desc.replace('\r\n', '\n') + imagelist = [] + + # First pass: Remove entire comparison sections + # Start by finding section headers for comparisons + comparison_sections = re.finditer(r"\[center\]\s*\[b\].*?(Comparison|vs).*?\[\/b\][\s\S]*?\[\/center\]", + desc, flags=re.IGNORECASE) + for section in comparison_sections: + section_text = section.group(0) + # If section contains hdbits.org, remove the entire section + if re.search(r"hdbits\.org", section_text, flags=re.IGNORECASE): + desc = desc.replace(section_text, '') + + # Handle individual comparison lines + comparison_lines = re.finditer(r"(.*comparison.*)\n", desc, flags=re.IGNORECASE) + for comp_match in comparison_lines: + comp_pos = comp_match.start() + + # Get the next lines after the comparison line + next_lines = desc[comp_pos:comp_pos+500].split('\n', 3)[:3] # Get comparison line + 2 more lines + next_lines_text = '\n'.join(next_lines) + + # Check if any of these lines contain HDBits URLs + if re.search(r"hdbits\.org", next_lines_text, flags=re.IGNORECASE): + # Replace the entire section (comparison line + next 2 lines) + line_end_pos = comp_pos + len(next_lines_text) + to_remove = desc[comp_pos:line_end_pos] + desc = desc.replace(to_remove, '') + + # Remove all empty URL tags containing hdbits.org + desc = re.sub(r"\[url=https?:\/\/(img\.|t\.)?hdbits\.org[^\]]*\]\[\/url\]", "", desc, flags=re.IGNORECASE) + + # Remove URL tags with visible content + hdbits_urls = re.findall(r"(\[url[\=\]]https?:\/\/(img\.|t\.)?hdbits\.org[^\]]+\])(.*?)(\[\/url\])?", desc, flags=re.IGNORECASE) + for url_parts in hdbits_urls: + full_url = ''.join(url_parts) + desc = desc.replace(full_url, '') + + # Remove HDBits image tags + hdbits_imgs = re.findall(r"\[img\][\s\S]*?(img\.|t\.)?hdbits\.org[\s\S]*?\[\/img\]", desc, flags=re.IGNORECASE) + for img_tag in hdbits_imgs: + desc = desc.replace(img_tag, '') + + # Remove any standalone HDBits URLs + standalone_urls = re.findall(r"https?:\/\/(img\.|t\.)?hdbits\.org\/[^\s\[\]]+", desc, flags=re.IGNORECASE) + for url in standalone_urls: + desc = desc.replace(url, '') + + # Catch any remaining URL tags with hdbits.org in them + desc = re.sub(r"\[url[^\]]*hdbits\.org[^\]]*\](.*?)\[\/url\]", "", desc, flags=re.IGNORECASE) + + # Double-check for any self-closing URL tags that might have been missed + desc = re.sub(r"\[url=https?:\/\/[^\]]*hdbits\.org[^\]]*\]\[\/url\]", "", desc, flags=re.IGNORECASE) + + # Remove empty comparison section headers and center tags + desc = re.sub(r"\[center\]\s*\[b\].*?(Comparison|vs).*?\[\/b\][\s\S]*?\[\/center\]", + "", desc, flags=re.IGNORECASE) + + # Remove any empty center tags that might be left + desc = re.sub(r"\[center\]\s*\[\/center\]", "", desc, flags=re.IGNORECASE) + + # Clean up multiple consecutive newlines + desc = re.sub(r"\n{3,}", "\n\n", desc) + + # Extract images wrapped in URL tags (e.g., [url=https://imgbox.com/xxx][img]https://thumbs.imgbox.com/xxx[/img][/url]) + url_img_pattern = r"\[url=(https?:\/\/[^\]]+)\]\[img\](https?:\/\/[^\]]+)\[\/img\]\[\/url\]" + url_img_matches = re.findall(url_img_pattern, desc, flags=re.IGNORECASE) + for web_url, img_url in url_img_matches: + # Skip HDBits images + if "hdbits.org" in web_url.lower() or "hdbits.org" in img_url.lower(): + desc = desc.replace(f"[url={web_url}][img]{img_url}[/img][/url]", '') + continue + + raw_url = img_url + if "thumbs2.imgbox.com" in img_url: + raw_url = img_url.replace("thumbs2.imgbox.com", "images2.imgbox.com") + raw_url = raw_url.replace("_t.png", "_o.png") + + image_dict = { + 'img_url': img_url, + 'raw_url': raw_url, + 'web_url': web_url + } + imagelist.append(image_dict) + desc = desc.replace(f"[url={web_url}][img]{img_url}[/img][/url]", '') + + description = desc.strip() + if self.is_only_bbcode(description): + return "", imagelist + return description, imagelist + + def clean_bhd_description(self, description, meta): + # Unescape html + desc = html.unescape(description) + desc = desc.replace('\r\n', '\n') + imagelist = [] + + if "framestor" in meta and meta['framestor']: + framestor_desc = desc + save_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid']) + os.makedirs(save_path, exist_ok=True) + nfo_file_path = os.path.join(save_path, "bhd.nfo") + with open(nfo_file_path, 'w', encoding='utf-8') as f: + try: + f.write(framestor_desc) + finally: + f.close() + console.print(f"[green]FraMeSToR NFO saved to {nfo_file_path}") + meta['nfo'] = True + meta['bhd_nfo'] = True + + # Remove size tags + desc = re.sub(r"\[size=.*?\]", "", desc) + desc = desc.replace("[/size]", "") + desc = desc.replace("<", "/") + desc = desc.replace("<", "\\") + + # Remove Images in IMG tags + desc = re.sub(r"\[img\][\s\S]*?\[\/img\]", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[img=[\s\S]*?\]", "", desc, flags=re.IGNORECASE) + + # Extract loose images and add to imagelist as dictionaries + loose_images = re.findall(r"(https?:\/\/[^\s\[\]]+\.(?:png|jpg))", desc, flags=re.IGNORECASE) + for img_url in loose_images: + image_dict = { + 'img_url': img_url, + 'raw_url': img_url, + 'web_url': img_url + } + imagelist.append(image_dict) + desc = desc.replace(img_url, '') + + # Now, remove matching URLs from [URL] tags + for img in imagelist: + img_url = re.escape(img['img_url']) + desc = re.sub(rf"\[URL={img_url}\]\[/URL\]", '', desc, flags=re.IGNORECASE) + desc = re.sub(rf"\[URL={img_url}\]\[img[^\]]*\]{img_url}\[/img\]\[/URL\]", '', desc, flags=re.IGNORECASE) + + # Remove leftover [img] or [URL] tags in the description + desc = re.sub(r"\[img\][\s\S]*?\[\/img\]", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[img=[\s\S]*?\]", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[URL=[\s\S]*?\]\[\/URL\]", "", desc, flags=re.IGNORECASE) + + if meta.get('flux', False): + # Strip trailing whitespace and newlines: + desc = desc.rstrip() + + # Strip blank lines: + desc = desc.strip('\n') + desc = re.sub("\n\n+", "\n\n", desc) + while desc.startswith('\n'): + desc = desc.replace('\n', '', 1) + desc = desc.strip('\n') + + if desc.replace('\n', '').strip() == '': + console.print("[yellow]Description is empty after cleaning.") + return "", imagelist + + description = f"[code]{desc}[/code]" + else: + description = "" + + if self.is_only_bbcode(description): + return "", imagelist + + return description, imagelist + def clean_ptp_description(self, desc, is_disc): + # console.print("[yellow]Cleaning PTP description...") + # Convert Bullet Points to - desc = desc.replace("•", "-") # Unescape html desc = html.unescape(desc) - # End my suffering desc = desc.replace('\r\n', '\n') # Remove url tags with PTP/HDB links - url_tags = re.findall("(\[url[\=\]]https?:\/\/passthepopcorn\.m[^\]]+)([^\[]+)(\[\/url\])?", desc, flags=re.IGNORECASE) - url_tags = url_tags + re.findall("(\[url[\=\]]https?:\/\/hdbits\.o[^\]]+)([^\[]+)(\[\/url\])?", desc, flags=re.IGNORECASE) - if url_tags != []: + url_tags = re.findall( + r"(?:\[url(?:=|\])[^\]]*https?:\/\/passthepopcorn\.m[^\]]*\]|\bhttps?:\/\/passthepopcorn\.m[^\s]+)", + desc, + flags=re.IGNORECASE, + ) + url_tags += re.findall(r"(\[url[\=\]]https?:\/\/hdbits\.o[^\]]+)([^\[]+)(\[\/url\])?", desc, flags=re.IGNORECASE) + if url_tags: for url_tag in url_tags: url_tag = ''.join(url_tag) - url_tag_removed = re.sub("(\[url[\=\]]https?:\/\/passthepopcorn\.m[^\]]+])", "", url_tag, flags=re.IGNORECASE) - url_tag_removed = re.sub("(\[url[\=\]]https?:\/\/hdbits\.o[^\]]+])", "", url_tag_removed, flags=re.IGNORECASE) + url_tag_removed = re.sub(r"(\[url[\=\]]https?:\/\/passthepopcorn\.m[^\]]+])", "", url_tag, flags=re.IGNORECASE) + url_tag_removed = re.sub(r"(\[url[\=\]]https?:\/\/hdbits\.o[^\]]+])", "", url_tag_removed, flags=re.IGNORECASE) url_tag_removed = url_tag_removed.replace("[/url]", "") desc = desc.replace(url_tag, url_tag_removed) - # Remove links to PTP + # Remove links to PTP/HDB desc = desc.replace('http://passthepopcorn.me', 'PTP').replace('https://passthepopcorn.me', 'PTP') desc = desc.replace('http://hdbits.org', 'HDB').replace('https://hdbits.org', 'HDB') - # Remove Mediainfo Tags / Attempt to regex out mediainfo - mediainfo_tags = re.findall("\[mediainfo\][\s\S]*?\[\/mediainfo\]", desc) - if len(mediainfo_tags) >= 1: - desc = re.sub("\[mediainfo\][\s\S]*?\[\/mediainfo\]", "", desc) - elif is_disc != "BDMV": - desc = re.sub("(^general\nunique)(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) - desc = re.sub("(^general\ncomplete)(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) - desc = re.sub("(^(Format[\s]{2,}:))(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) - desc = re.sub("(^(video|audio|text)( #\d+)?\nid)(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) - desc = re.sub("(^(menu)( #\d+)?\n)(.*?)^$", "", f"{desc}\n\n", flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) - elif any(x in is_disc for x in ["BDMV", "DVD"]): - return "" + # Catch Stray Images and Prepare Image List + imagelist = [] + excluded_urls = set() + + source_encode_comps = re.findall(r"\[comparison=Source, Encode\][\s\S]*", desc, flags=re.IGNORECASE) + source_vs_encode_sections = re.findall(r"Source Vs Encode:[\s\S]*", desc, flags=re.IGNORECASE) + specific_cases = source_encode_comps + source_vs_encode_sections + # Extract URLs and update excluded_urls + for block in specific_cases: + urls = re.findall(r"(https?:\/\/[^\s\[\]]+\.(?:png|jpg))", block, flags=re.IGNORECASE) + excluded_urls.update(urls) + desc = desc.replace(block, '') + + # General [comparison=...] handling + comps = re.findall(r"\[comparison=[\s\S]*?\[\/comparison\]", desc, flags=re.IGNORECASE) + hides = re.findall(r"\[hide[\s\S]*?\[\/hide\]", desc, flags=re.IGNORECASE) + comps.extend(hides) + nocomp = desc + + # Exclude URLs from excluded array fom `nocomp` + for url in excluded_urls: + nocomp = nocomp.replace(url, '') + + comp_placeholders = [] + + # Replace comparison/hide tags with placeholder because sometimes uploaders use comp images as loose images + for i, comp in enumerate(comps): + nocomp = nocomp.replace(comp, '') + desc = desc.replace(comp, f"COMPARISON_PLACEHOLDER-{i} ") + comp_placeholders.append(comp) + + # as the name implies, protect image links while doing regex things + def protect_links(desc): + links = re.findall(r'https?://\S+', desc) + for i, link in enumerate(links): + desc = desc.replace(link, f'__LINK_PLACEHOLDER_{i}__') + return desc, links + + def restore_links(desc, links): + for i, link in enumerate(links): + desc = desc.replace(f'__LINK_PLACEHOLDER_{i}__', link) + return desc + + links = [] + + if is_disc == "DVD": + desc = re.sub(r"\[mediainfo\][\s\S]*?\[\/mediainfo\]", "", desc) + + elif is_disc == "BDMV": + desc = re.sub(r"\[mediainfo\][\s\S]*?\[\/mediainfo\]", "", desc) + desc = re.sub(r"DISC INFO:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Disc Title:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Disc Size:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Protection:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"BD-Java:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"BDInfo:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"PLAYLIST REPORT:[\s\S]*?(?=\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Name:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Length:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Size:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Total Bitrate:[\s\S]*?(\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"VIDEO:[\s\S]*?(?=\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"AUDIO:[\s\S]*?(?=\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"SUBTITLES:[\s\S]*?(?=\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Codec\s+Bitrate\s+Description[\s\S]*?(?=\n\n|$)", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"Codec\s+Language\s+Bitrate\s+Description[\s\S]*?(?=\n\n|$)", "", desc, flags=re.IGNORECASE) + + else: + desc = re.sub(r"\[mediainfo\][\s\S]*?\[\/mediainfo\]", "", desc) + desc = re.sub(r"(^general\nunique)(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) + desc = re.sub(r"(^general\ncomplete)(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) + desc = re.sub(r"(^(Format[\s]{2,}:))(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) + desc = re.sub(r"(^(video|audio|text)( #\d+)?\nid)(.*?)^$", "", desc, flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) + desc = re.sub(r"(^(menu)( #\d+)?\n)(.*?)^$", "", f"{desc}\n\n", flags=re.MULTILINE | re.IGNORECASE | re.DOTALL) + + desc, links = protect_links(desc) + + desc = re.sub( + r"\[b\](.*?)(Matroska|DTS|AVC|x264|Progressive|23\.976 fps|16:9|[0-9]+x[0-9]+|[0-9]+ MiB|[0-9]+ Kbps|[0-9]+ bits|cabac=.*?/ aq=.*?|\d+\.\d+ Mbps)\[/b\]", + "", + desc, + flags=re.IGNORECASE | re.DOTALL, + ) + desc = re.sub( + r"(Matroska|DTS|AVC|x264|Progressive|23\.976 fps|16:9|[0-9]+x[0-9]+|[0-9]+ MiB|[0-9]+ Kbps|[0-9]+ bits|cabac=.*?/ aq=.*?|\d+\.\d+ Mbps|[0-9]+\s+channels|[0-9]+\.[0-9]+\s+KHz|[0-9]+ KHz|[0-9]+\s+bits)", + "", + desc, + flags=re.IGNORECASE | re.DOTALL, + ) + desc = re.sub( + r"\[u\](Format|Bitrate|Channels|Sampling Rate|Resolution):\[/u\]\s*\d*.*?", + "", + desc, + flags=re.IGNORECASE, + ) + desc = re.sub( + r"^\s*\d+\s*(channels|KHz|bits)\s*$", + "", + desc, + flags=re.MULTILINE | re.IGNORECASE, + ) + + desc = re.sub(r"^\s+$", "", desc, flags=re.MULTILINE) + desc = re.sub(r"\n{2,}", "\n", desc) + + desc = restore_links(desc, links) # Convert Quote tags: - desc = re.sub("\[quote.*?\]", "[code]", desc) + desc = re.sub(r"\[quote.*?\]", "[code]", desc) desc = desc.replace("[/quote]", "[/code]") - + # Remove Alignments: - desc = re.sub("\[align=.*?\]", "", desc) + desc = re.sub(r"\[align=.*?\]", "", desc) desc = desc.replace("[/align]", "") # Remove size tags - desc = re.sub("\[size=.*?\]", "", desc) + desc = re.sub(r"\[size=.*?\]", "", desc) desc = desc.replace("[/size]", "") # Remove Videos - desc = re.sub("\[video\][\s\S]*?\[\/video\]", "", desc) + desc = re.sub(r"\[video\][\s\S]*?\[\/video\]", "", desc) # Remove Staff tags - desc = re.sub("\[staff[\s\S]*?\[\/staff\]", "", desc) - + desc = re.sub(r"\[staff[\s\S]*?\[\/staff\]", "", desc) - #Remove Movie/Person/User/hr/Indent + # Remove Movie/Person/User/hr/Indent remove_list = [ '[movie]', '[/movie]', '[artist]', '[/artist]', @@ -103,34 +373,27 @@ def clean_ptp_description(self, desc, is_disc): ] for each in remove_list: desc = desc.replace(each, '') - - #Catch Stray Images - comps = re.findall("\[comparison=[\s\S]*?\[\/comparison\]", desc) - hides = re.findall("\[hide[\s\S]*?\[\/hide\]", desc) - comps.extend(hides) - nocomp = desc - comp_placeholders = [] - # Replace comparison/hide tags with placeholder because sometimes uploaders use comp images as loose images - for i in range(len(comps)): - nocomp = nocomp.replace(comps[i], '') - desc = desc.replace(comps[i], f"COMPARISON_PLACEHOLDER-{i} ") - comp_placeholders.append(comps[i]) - - - # Remove Images in IMG tags: - desc = re.sub("\[img\][\s\S]*?\[\/img\]", "", desc, flags=re.IGNORECASE) - desc = re.sub("\[img=[\s\S]*?\]", "", desc, flags=re.IGNORECASE) - # Replace Images - loose_images = re.findall("(https?:\/\/.*\.(?:png|jpg))", nocomp, flags=re.IGNORECASE) - if len(loose_images) >= 1: - for image in loose_images: - desc = desc.replace(image, '') + # Remove Images in IMG tags + desc = re.sub(r"\[img\][\s\S]*?\[\/img\]", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[img=[\s\S]*?\]", "", desc, flags=re.IGNORECASE) + + # Extract loose images and add to imagelist as dictionaries + loose_images = re.findall(r"(https?:\/\/[^\s\[\]]+\.(?:png|jpg))", nocomp, flags=re.IGNORECASE) + for img_url in loose_images: + if img_url not in excluded_urls: # Only include URLs not part of excluded sections + image_dict = { + 'img_url': img_url, + 'raw_url': img_url, + 'web_url': img_url + } + imagelist.append(image_dict) + desc = desc.replace(img_url, '') + # Re-place comparisons - if comp_placeholders != []: - for i, comp in enumerate(comp_placeholders): - comp = re.sub("\[\/?img[\s\S]*?\]", "",comp, flags=re.IGNORECASE) - desc = desc.replace(f"COMPARISON_PLACEHOLDER-{i} ", comp) + for i, comp in enumerate(comp_placeholders): + comp = re.sub(r"\[\/?img[\s\S]*?\]", "", comp, flags=re.IGNORECASE) + desc = desc.replace(f"COMPARISON_PLACEHOLDER-{i} ", comp) # Convert hides with multiple images to comparison desc = self.convert_collapse_to_comparison(desc, "hide", hides) @@ -142,25 +405,27 @@ def clean_ptp_description(self, desc, is_disc): desc = desc.replace('\n', '', 1) desc = desc.strip('\n') - if desc.replace('\n', '') == '': - return "" - return desc + if desc.replace('\n', '').strip() == '': + return "", imagelist + if self.is_only_bbcode(desc): + return "", imagelist + + return desc, imagelist - def clean_unit3d_description(self, desc, site): - # Unescape html + # Unescape HTML desc = html.unescape(desc) - # End my suffering + # Replace carriage returns with newlines desc = desc.replace('\r\n', '\n') # Remove links to site site_netloc = urllib.parse.urlparse(site).netloc - site_regex = f"(\[url[\=\]]https?:\/\/{site_netloc}/[^\]]+])([^\[]+)(\[\/url\])?" + site_regex = rf"(\[url[\=\]]https?:\/\/{site_netloc}/[^\]]+])([^\[]+)(\[\/url\])?" site_url_tags = re.findall(site_regex, desc) - if site_url_tags != []: + if site_url_tags: for site_url_tag in site_url_tags: site_url_tag = ''.join(site_url_tag) - url_tag_regex = f"(\[url[\=\]]https?:\/\/{site_netloc}[^\]]+])" + url_tag_regex = rf"(\[url[\=\]]https?:\/\/{site_netloc}[^\]]+])" url_tag_removed = re.sub(url_tag_regex, "", site_url_tag) url_tag_removed = url_tag_removed.replace("[/url]", "") desc = desc.replace(site_url_tag, url_tag_removed) @@ -168,98 +433,120 @@ def clean_unit3d_description(self, desc, site): desc = desc.replace(site_netloc, site_netloc.split('.')[0]) # Temporarily hide spoiler tags - spoilers = re.findall("\[spoiler[\s\S]*?\[\/spoiler\]", desc) + spoilers = re.findall(r"\[spoiler[\s\S]*?\[\/spoiler\]", desc) nospoil = desc spoiler_placeholders = [] for i in range(len(spoilers)): nospoil = nospoil.replace(spoilers[i], '') desc = desc.replace(spoilers[i], f"SPOILER_PLACEHOLDER-{i} ") spoiler_placeholders.append(spoilers[i]) - - # Get Images from outside spoilers - imagelist = [] - url_tags = re.findall("\[url=[\s\S]*?\[\/url\]", desc) - if url_tags != []: - for tag in url_tags: - image = re.findall("\[img[\s\S]*?\[\/img\]", tag) - if len(image) == 1: - image_dict = {} - img_url = image[0].lower().replace('[img]', '').replace('[/img]', '') - image_dict['img_url'] = image_dict['raw_url'] = re.sub("\[img[\s\S]*\]", "", img_url) - url_tag = tag.replace(image[0], '') - image_dict['web_url'] = re.match("\[url=[\s\S]*?\]", url_tag, flags=re.IGNORECASE)[0].lower().replace('[url=', '')[:-1] - imagelist.append(image_dict) - desc = desc.replace(tag, '') - # Remove bot signatures - desc = desc.replace("[img=35]https://blutopia/favicon.ico[/img] [b]Uploaded Using [url=https://github.com/HDInnovations/UNIT3D]UNIT3D[/url] Auto Uploader[/b] [img=35]https://blutopia/favicon.ico[/img]", '') - desc = re.sub("\[center\].*Created by L4G's Upload Assistant.*\[\/center\]", "", desc, flags=re.IGNORECASE) + # Get Images from [img] tags and remove them from the description + imagelist = [] + img_tags = re.findall(r"\[img[^\]]*\](.*?)\[/img\]", desc, re.IGNORECASE) + if img_tags: + for img_url in img_tags: + image_dict = { + 'img_url': img_url.strip(), + 'raw_url': img_url.strip(), + 'web_url': img_url.strip(), + } + imagelist.append(image_dict) + # Remove the [img] tag and its contents from the description + desc = re.sub(rf"\[img[^\]]*\]{re.escape(img_url)}\[/img\]", '', desc, flags=re.IGNORECASE) + + # Now, remove matching URLs from [URL] tags + for img in imagelist: + img_url = re.escape(img['img_url']) + desc = re.sub(rf"\[URL={img_url}\]\[/URL\]", '', desc, flags=re.IGNORECASE) + desc = re.sub(rf"\[URL={img_url}\]\[img[^\]]*\]{img_url}\[/img\]\[/URL\]", '', desc, flags=re.IGNORECASE) + + # Filter out bot images from imagelist + bot_image_urls = [ + "https://blutopia.xyz/favicon.ico", # Example bot image URL + "https://i.ibb.co/2NVWb0c/uploadrr.webp", + "https://blutopia/favicon.ico", + "https://ptpimg.me/606tk4.png", + # Add any other known bot image URLs here + ] + imagelist = [ + img for img in imagelist + if img['img_url'] not in bot_image_urls and not re.search(r'thumbs', img['img_url'], re.IGNORECASE) + ] - # Replace spoiler tags - if spoiler_placeholders != []: + # Restore spoiler tags + if spoiler_placeholders: for i, spoiler in enumerate(spoiler_placeholders): desc = desc.replace(f"SPOILER_PLACEHOLDER-{i} ", spoiler) - # Check for empty [center] tags - centers = re.findall("\[center[\s\S]*?\[\/center\]", desc) - if centers != []: + # Check for and clean up empty [center] tags + centers = re.findall(r"\[center[\s\S]*?\[\/center\]", desc) + if centers: for center in centers: - full_center = center - replace = ['[center]', ' ', '\n', '[/center]'] - for each in replace: - center = center.replace(each, '') - if center == "": - desc = desc.replace(full_center, '') + # If [center] contains only whitespace or empty tags, remove the entire tag + cleaned_center = re.sub(r'\[center\]\s*\[\/center\]', '', center) + cleaned_center = re.sub(r'\[center\]\s+', '[center]', cleaned_center) + cleaned_center = re.sub(r'\s*\[\/center\]', '[/center]', cleaned_center) + if cleaned_center == '[center][/center]': + desc = desc.replace(center, '') + else: + desc = desc.replace(center, cleaned_center.strip()) - # Convert Comparison spoilers to [comparison=] - desc = self.convert_collapse_to_comparison(desc, "spoiler", spoilers) - - # Strip blank lines: - desc = desc.strip('\n') - desc = re.sub("\n\n+", "\n\n", desc) - while desc.startswith('\n'): - desc = desc.replace('\n', '', 1) - desc = desc.strip('\n') + # Remove bot signatures + bot_signature_regex = r""" + \[center\]\s*\[img=\d+\]https:\/\/blutopia\.xyz\/favicon\.ico\[\/img\]\s*\[b\] + Uploaded\sUsing\s\[url=https:\/\/github\.com\/HDInnovations\/UNIT3D\]UNIT3D\[\/url\]\s + Auto\sUploader\[\/b\]\s*\[img=\d+\]https:\/\/blutopia\.xyz\/favicon\.ico\[\/img\]\s*\[\/center\]| + \[center\]\s*\[b\]Uploaded\sUsing\s\[url=https:\/\/github\.com\/HDInnovations\/UNIT3D\]UNIT3D\[\/url\] + \sAuto\sUploader\[\/b\]\s*\[\/center\]| + \[center\]\[url=https:\/\/github\.com\/z-ink\/uploadrr\]\[img=\d+\]https:\/\/i\.ibb\.co\/2NVWb0c\/uploadrr\.webp\[\/img\]\[\/url\]\[\/center\]| + \n\[center\]\[url=https:\/\/github\.com\/edge20200\/Only-Uploader\]Powered\sby\s + Only-Uploader\[\/url\]\[\/center\] + """ + desc = re.sub(bot_signature_regex, "", desc, flags=re.IGNORECASE | re.VERBOSE) + desc = re.sub(r"\[center\].*Created by.*Upload Assistant.*\[\/center\]", "", desc, flags=re.IGNORECASE) + + # Remove leftover [img] or [URL] tags in the description + desc = re.sub(r"\[img\][\s\S]*?\[\/img\]", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[img=[\s\S]*?\]", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[URL=[\s\S]*?\]\[\/URL\]", "", desc, flags=re.IGNORECASE) + + # Strip trailing whitespace and newlines: + desc = desc.rstrip() if desc.replace('\n', '') == '': return "", imagelist + if self.is_only_bbcode(desc): + return "", imagelist return desc, imagelist - - - - - - - - - - - - - - + def is_only_bbcode(self, desc): + # Remove all BBCode tags + text = re.sub(r"\[/?[a-zA-Z0-9]+(?:=[^\]]*)?\]", "", desc) + # Remove whitespace and newlines + text = text.strip() + # If nothing left, it's only BBCode + return not text def convert_pre_to_code(self, desc): desc = desc.replace('[pre]', '[code]') desc = desc.replace('[/pre]', '[/code]') return desc - def convert_hide_to_spoiler(self, desc): desc = desc.replace('[hide', '[spoiler') desc = desc.replace('[/hide]', '[/spoiler]') return desc - + def convert_spoiler_to_hide(self, desc): desc = desc.replace('[spoiler', '[hide') desc = desc.replace('[/spoiler]', '[/hide]') return desc def remove_spoiler(self, desc): - desc = re.sub("\[\/?spoiler[\s\S]*?\]", "", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[\/?spoiler[\s\S]*?\]", "", desc, flags=re.IGNORECASE) return desc - + def convert_spoiler_to_code(self, desc): desc = desc.replace('[spoiler', '[code') desc = desc.replace('[/spoiler]', '[/code]') @@ -269,15 +556,15 @@ def convert_code_to_quote(self, desc): desc = desc.replace('[code', '[quote') desc = desc.replace('[/code]', '[/quote]') return desc - + def convert_comparison_to_collapse(self, desc, max_width): - comparisons = re.findall("\[comparison=[\s\S]*?\[\/comparison\]", desc) + comparisons = re.findall(r"\[comparison=[\s\S]*?\[\/comparison\]", desc) for comp in comparisons: line = [] output = [] comp_sources = comp.split(']', 1)[0].replace('[comparison=', '').replace(' ', '').split(',') comp_images = comp.split(']', 1)[1].replace('[/comparison]', '').replace(',', '\n').replace(' ', '\n') - comp_images = re.findall("(https?:\/\/.*\.(?:png|jpg))", comp_images, flags=re.IGNORECASE) + comp_images = re.findall(r"(https?:\/\/.*\.(?:png|jpg))", comp_images, flags=re.IGNORECASE) screens_per_line = len(comp_sources) img_size = int(max_width / screens_per_line) if img_size > 350: @@ -295,15 +582,15 @@ def convert_comparison_to_collapse(self, desc, max_width): desc = desc.replace(comp, new_bbcode) return desc - def convert_comparison_to_centered(self, desc, max_width): - comparisons = re.findall("\[comparison=[\s\S]*?\[\/comparison\]", desc) + comparisons = re.findall(r"\[comparison=[\s\S]*?\[\/comparison\]", desc) for comp in comparisons: line = [] output = [] - comp_sources = comp.split(']', 1)[0].replace('[comparison=', '').replace(' ', '').split(',') + comp_sources = comp.split(']', 1)[0].replace('[comparison=', '').strip() + comp_sources = re.split(r"\s*,\s*", comp_sources) comp_images = comp.split(']', 1)[1].replace('[/comparison]', '').replace(',', '\n').replace(' ', '\n') - comp_images = re.findall("(https?:\/\/.*\.(?:png|jpg))", comp_images, flags=re.IGNORECASE) + comp_images = re.findall(r"(https?:\/\/.*\.(?:png|jpg))", comp_images, flags=re.IGNORECASE) screens_per_line = len(comp_sources) img_size = int(max_width / screens_per_line) if img_size > 350: @@ -326,17 +613,17 @@ def convert_collapse_to_comparison(self, desc, spoiler_hide, collapses): if collapses != []: for i in range(len(collapses)): tag = collapses[i] - images = re.findall("\[img[\s\S]*?\[\/img\]", tag, flags=re.IGNORECASE) + images = re.findall(r"\[img[\s\S]*?\[\/img\]", tag, flags=re.IGNORECASE) if len(images) >= 6: comp_images = [] final_sources = [] for image in images: - image_url = re.sub("\[img[\s\S]*\]", "", image.replace('[/img]', ''), flags=re.IGNORECASE) + image_url = re.sub(r"\[img[\s\S]*\]", "", image.replace('[/img]', ''), flags=re.IGNORECASE) comp_images.append(image_url) if spoiler_hide == "spoiler": - sources = re.match("\[spoiler[\s\S]*?\]", tag)[0].replace('[spoiler=', '')[:-1] + sources = re.match(r"\[spoiler[\s\S]*?\]", tag)[0].replace('[spoiler=', '')[:-1] elif spoiler_hide == "hide": - sources = re.match("\[hide[\s\S]*?\]", tag)[0].replace('[hide=', '')[:-1] + sources = re.match(r"\[hide[\s\S]*?\]", tag)[0].replace('[hide=', '')[:-1] sources = re.sub("comparison", "", sources, flags=re.IGNORECASE) for each in ['vs', ',', '|']: sources = sources.split(each) @@ -348,4 +635,4 @@ def convert_collapse_to_comparison(self, desc, spoiler_hide, collapses): final_sources = ', '.join(final_sources) spoil2comp = f"[comparison={final_sources}]{comp_images}[/comparison]" desc = desc.replace(tag, spoil2comp) - return desc \ No newline at end of file + return desc diff --git a/src/bluray_com.py b/src/bluray_com.py new file mode 100644 index 000000000..e28fc7fa5 --- /dev/null +++ b/src/bluray_com.py @@ -0,0 +1,1837 @@ +import httpx +import random +import asyncio +import re +import json +import cli_ui +import os +from bs4 import BeautifulSoup +from rich.console import Console + +console = Console() + + +async def search_bluray(meta): + imdb_id = f"tt{meta['imdb_id']:07d}" + url = f"https://www.blu-ray.com/search/?quicksearch=1&quicksearch_country=all&quicksearch_keyword={imdb_id}§ion=theatrical" + debug_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/debug_bluray_search_{imdb_id}.html" + + try: + if os.path.exists(debug_filename): + if meta['debug']: + console.print(f"[green]Found existing file for {imdb_id}[/green]") + with open(debug_filename, "r", encoding="utf-8") as f: + response_text = f.read() + + if response_text and "No index" not in response_text: + return response_text + else: + console.print("[yellow]Cached file exists but appears to be invalid, will fetch fresh data[/yellow]") + except Exception as e: + console.print(f"[yellow]Error reading cached file: {str(e)}[/yellow]") + + # If we're here, we need to make a request + console.print(f"[dim]Search URL: {url}[/dim]") + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Referer": "https://www.blu-ray.com/", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-User": "?1", + "Upgrade-Insecure-Requests": "1", + "Cache-Control": "max-age=0" + } + + max_retries = 2 + retry_count = 0 + backoff_time = 3.0 + response_text = None + + while retry_count <= max_retries: + try: + delay = random.uniform(1, 3) + if meta['debug']: + console.print(f"[dim]Waiting {delay:.2f} seconds before request (attempt {retry_count + 1}/{max_retries + 1})...[/dim]") + await asyncio.sleep(delay) + + if meta['debug']: + console.print(f"[yellow]Sending request to blu-ray.com (attempt {retry_count + 1}/{max_retries + 1})...[/yellow]") + async with httpx.AsyncClient(timeout=10.0, follow_redirects=True) as client: + response = await client.get(url, headers=headers) + + if response.status_code == 200 and "No index" not in response.text: + response_text = response.text + + try: + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/debug_bluray_search_{imdb_id}.html", "w", encoding="utf-8") as f: + f.write(response_text) + if meta['debug']: + console.print(f"[dim]Saved search response to debug_bluray_search_{imdb_id}.html[/dim]") + except Exception as e: + console.print(f"[dim]Could not save debug file: {str(e)}[/dim]") + + break + + elif "No index" in response.text: + console.print(f"[red]Blocked by blu-ray.com (Anti-scraping protection) (attempt {retry_count + 1}/{max_retries + 1})[/red]") + console.print(f"[dim]Response preview: {response.text[:150]}...[/dim]") + + # use less retries for blocked requests since it's probably just borked + if retry_count < 2: + backoff_time *= 2 + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on search[/red]") + break + else: + console.print(f"[red]Failed with status code: {response.status_code} (attempt {retry_count + 1}/{max_retries + 1})[/red]") + + if retry_count < max_retries: + backoff_time *= 2 + if meta['debug']: + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on search[/red]") + break + + except httpx.RequestError as e: + console.print(f"[red]HTTP request error when accessing {url} (attempt {retry_count + 1}/{max_retries + 1}): {str(e)}[/red]") + if retry_count < max_retries: + backoff_time *= 2 + if meta['debug']: + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on search[/red]") + break + + if not response_text: + console.print("[red]Failed to retrieve search results after all attempts[/red]") + return None + + return response_text + + +def extract_bluray_links(html_content): + if not html_content: + console.print("[red]No HTML content to extract links from[/red]") + return None + + results = [] + + try: + soup = BeautifulSoup(html_content, 'lxml') + movie_divs = soup.select('div.figure') + if not movie_divs: + console.print("[red]No movie divs found in the search results[/red]") + return None + + for i, movie_div in enumerate(movie_divs, 1): + link = movie_div.find('a', class_='alphaborder') + + if link and 'href' in link.attrs: + movie_url = link['href'] + releases_url = f"{movie_url}#Releases" + title_div = movie_div.select_one('div.figurecaptionbottom div[style*="font-weight: bold"]') + year_div = movie_div.select_one('div.figurecaptionbottom div[style*="margin-top"]') + + title = title_div.text.strip() if title_div else "Unknown Title" + year = year_div.text.strip() if year_div else "Unknown Year" + + console.print(f"[green]Found movie: {title} ({year})[/green]") + console.print(f"[dim]URL: {releases_url}[/dim]") + + results.append({ + 'title': title, + 'year': year, + 'releases_url': releases_url + }) + else: + console.print("[red]Movie div doesn't have a valid link[/red]") + + return results + + except Exception as e: + console.print(f"[red]Error parsing HTML: {str(e)}[/red]") + console.print_exception() + return None + + +async def extract_bluray_release_info(html_content, meta): + if not html_content: + console.print("[red]No HTML content to extract release info from[/red]") + return [] + + matching_releases = [] + is_3d = meta.get('3D', '') == 'yes' + resolution = meta.get('resolution', '').lower() + is_4k = '2160p' in resolution or '4k' in resolution + is_dvd = meta['is_disc'] == "DVD" + release_type = "4K" if is_4k else "3D" if is_3d else "DVD" if is_dvd else "BD" + + if is_3d: + console.print("[blue]Looking for 3D Blu-ray releases[/blue]") + elif is_4k: + console.print("[blue]Looking for 4K/UHD Blu-ray releases[/blue]") + elif is_dvd: + console.print("[blue]Looking for DVD releases[/blue]") + else: + console.print("[blue]Looking for standard Blu-ray releases[/blue]") + + try: + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/debug_bluray_{release_type}.html", "w", encoding="utf-8") as f: + f.write(html_content) + if meta['debug']: + console.print(f"[dim]Saved releases response to debug_bluray_{release_type}.html[/dim]") + except Exception as e: + console.print(f"[dim]Could not save debug file: {str(e)}[/dim]") + + try: + dvd_sections = None + soup = BeautifulSoup(html_content, 'lxml') + if is_dvd: + dvd_sections = soup.find_all('h3', string=lambda s: s and ('DVD Editions' in s)) + else: + bluray_sections = soup.find_all('h3', string=lambda s: s and ('Blu-ray Editions' in s or '4K Blu-ray Editions' in s or '3D Blu-ray Editions' in s)) + if dvd_sections: + selected_sections = dvd_sections + else: + selected_sections = bluray_sections + if meta['debug']: + release_type_debug = "DVD" if is_dvd else "Blu-ray" + console.print(f"[blue]Found {len(selected_sections)} {release_type_debug} section(s)[/blue]") + filtered_sections = [] + for section in selected_sections: + section_title = section.text + + # Check if this section matches what we're looking for + if is_3d and '3D Blu-ray Editions' in section_title: + filtered_sections.append(section) + if meta['debug']: + console.print(f"[green]Including 3D section: {section_title}[/green]") + elif is_4k and '4K Blu-ray Editions' in section_title: + filtered_sections.append(section) + if meta['debug']: + console.print(f"[green]Including 4K section: {section_title}[/green]") + elif is_dvd and 'DVD Editions' in section_title: + filtered_sections.append(section) + if meta['debug']: + console.print(f"[green]Including DVD section: {section_title}[/green]") + elif not is_3d and not is_4k and 'Blu-ray Editions' in section_title and '3D Blu-ray Editions' not in section_title and '4K Blu-ray Editions' not in section_title: + filtered_sections.append(section) + if meta['debug']: + console.print(f"[green]Including standard Blu-ray section: {section_title}[/green]") + + # If no sections match our filter criteria, use all sections + if not filtered_sections: + console.print("[yellow]No sections match exact media type, using all available sections[/yellow]") + filtered_sections = selected_sections + + for section_idx, section in enumerate(filtered_sections, 1): + parent_tr = section.find_parent('tr') + if not parent_tr: + console.print(f"[red]Could not find parent tr for {release_type_debug} section[/red]") + continue + + release_links = [] + current = section.find_next() + while current and (not current.name == 'h3'): + if current.name == 'a' and current.has_attr('href') and ('blu-ray.com/movies/' in current['href'] or 'blu-ray.com/dvd/' in current['href']): + release_links.append(current) + current = current.find_next() + + for link_idx, link in enumerate(release_links, 1): + try: + release_url = link['href'] + title = link.get('title', link.text.strip()) + country_flag = link.find_previous('img', width='18', height='12') + country = country_flag.get('title', 'Unknown') if country_flag else 'Unknown' + price_tag = link.find_next('small', style=lambda s: s and 'color: green' in s) + price = price_tag.text.strip() if price_tag else "Unknown" + publisher_tag = link.find_next('small', style=lambda s: s and 'color: #999999' in s) + publisher = publisher_tag.text.strip() if publisher_tag else "Unknown" + + release_id_match = re.search(r'blu-ray\.com/(movies|dvd)/.*?/(\d+)/', release_url) + if release_id_match: + release_id = release_id_match.group(1) + if meta['debug']: + console.print(f"[green]Found release ID: {release_id}[/green]") + + matching_releases.append({ + 'title': title, + 'url': release_url, + 'price': price, + 'publisher': publisher, + 'country': country, + 'release_id': release_id + }) + else: + console.print(f"[red]Could not extract release ID from URL: {release_url}[/red]") + + except Exception as e: + console.print(f"[red]Error processing release: {str(e)}[/red]") + console.print_exception() + + console.print(f"[green]Found {len(matching_releases)} potential matching releases[/green]") + return matching_releases + + except Exception as e: + console.print(f"[red]Error parsing Blu-ray release HTML: {str(e)}[/red]") + console.print_exception() + return [] + + +async def extract_product_id(url, meta): + pattern = r'blu-ray\.com/.*?/(\d+)/' + match = re.search(pattern, url) + + if match: + product_id = match.group(1) + if meta['debug']: + console.print(f"[green]Successfully extracted product ID: {product_id}[/green]") + return product_id + + console.print(f"[red]Could not extract product ID from URL: {url}[/red]") + return None + + +async def get_bluray_releases(meta): + console.print("[blue]===== Starting blu-ray.com release search =====[/blue]") + console.print(f"[blue]Movie: {meta.get('filename', 'Unknown')}, IMDB ID: tt{meta.get('imdb_id', '0000000'):07d}[/blue]") + + html_content = await search_bluray(meta) + + if not html_content: + console.print("[red]Failed to get search results from blu-ray.com[/red]") + return [] + + movie_links = extract_bluray_links(html_content) + + if not movie_links: + console.print(f"[red]No movies found for IMDB ID: tt{meta['imdb_id']:07d}[/red]") + return [] + + matching_releases = [] + + for idx, movie in enumerate(movie_links, 1): + if meta['debug']: + console.print(f"[blue]Processing movie {idx}/{len(movie_links)}: {movie['title']} ({movie['year']})[/blue]") + releases_url = movie['releases_url'] + product_id = await extract_product_id(releases_url, meta) + if not product_id: + console.print(f"[red]Could not extract product ID from {releases_url}[/red]") + continue + + ajax_url = f"https://www.blu-ray.com/products/menu_ajax.php?p={product_id}&c=20&action=showreleasesall" + console.print(f"[dim]Releases URL: {ajax_url}[/dim]") + + is_3d = meta.get('3D', '') == 'yes' + resolution = meta.get('resolution', '').lower() + is_4k = '2160p' in resolution or '4k' in resolution + release_type = "4K" if is_4k else "3D" if is_3d else "BD" + release_debug_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/debug_bluray_{release_type}.html" + + try: + if os.path.exists(release_debug_filename): + if meta['debug']: + console.print(f"[green]Found existing release data for product ID {product_id}[/green]") + with open(release_debug_filename, "r", encoding="utf-8") as f: + response_text = f.read() + + if response_text and "No index" not in response_text: + movie_releases = await extract_bluray_release_info(response_text, meta) + + for release in movie_releases: + release['movie_title'] = movie['title'] + release['movie_year'] = movie['year'] + + matching_releases.extend(movie_releases) + continue + else: + console.print("[yellow]Cached file exists but appears to be invalid, will fetch fresh data[/yellow]") + except Exception as e: + console.print(f"[yellow]Error reading cached file: {str(e)}[/yellow]") + + # If we're here, we need to make a request + delay = random.uniform(2, 4) + if meta['debug']: + console.print(f"[dim]Waiting {delay:.2f} seconds before request...[/dim]") + await asyncio.sleep(delay) + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Referer": releases_url, + "X-Requested-With": "XMLHttpRequest", + } + + try: + max_retries = 2 + retry_count = 0 + backoff_time = 3.0 + + while retry_count <= max_retries: + try: + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + response = await client.get(ajax_url, headers=headers) + + if response.status_code == 200 and "No index" not in response.text: + movie_releases = await extract_bluray_release_info(response.text, meta) + + for release in movie_releases: + release['movie_title'] = movie['title'] + release['movie_year'] = movie['year'] + + console.print(f"[green]Found {len(movie_releases)} matching releases for this movie[/green]") + matching_releases.extend(movie_releases) + break + elif "No index" in response.text: + console.print(f"[red]Blocked by blu-ray.com when accessing {ajax_url} (attempt {retry_count + 1}/{max_retries + 1})[/red]") + if retry_count < max_retries: + backoff_time *= 2 + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on this URL[/red]") + break + else: + console.print(f"[red]Failed to get release information from {ajax_url}, status code: {response.status_code} (attempt {retry_count + 1}/{max_retries + 1})[/red]") + if retry_count < max_retries: + backoff_time *= 2 + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on this URL[/red]") + break + + except httpx.RequestError as e: + console.print(f"[red]HTTP request error when accessing {ajax_url} (attempt {retry_count + 1}/{max_retries + 1}): {str(e)}[/red]") + if retry_count < max_retries: + backoff_time *= 2 + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on this URL[/red]") + break + + except Exception as e: + console.print(f"[red]Error fetching release details from {ajax_url}: {str(e)}[/red]") + console.print_exception() + + console.print("[yellow]===== BluRay.com search results summary =====[/yellow]") + + if matching_releases: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + for idx, release in enumerate(matching_releases, 1): + console.print(f"[green]{idx}. {release['movie_title']} ({release['movie_year']}):[/green]") + console.print(f" [blue]Title: {release['title']}[/blue]") + console.print(f" [blue]Country: {release['country']}[/blue]") + console.print(f" [blue]Publisher: {release['publisher']}[/blue]") + console.print(f" [blue]Price: {release['price']}[/blue]") + console.print(f" [dim]URL: {release['url']}[/dim]") + + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print() + console.print("[green]Release Selection") + console.print("[green]=======================================") + console.print("[dim]Please select a release to use for region and distributor information:") + console.print("[dim]Enter release number, 'a' for all releases, or 'n' to skip") + console.print("[dim]Selecting all releases will search every release for more information...") + console.print("[dim]More releases will require more time to process") + else: + console.print("[yellow]Unattended mode - selecting all releases") + + while True: + try: + selection = input(f"Selection (1-{len(matching_releases)}/a/n): ").strip().lower() + if selection == 'a': + cli_ui.info("All releases selected") + detailed_releases = await process_all_releases(matching_releases, meta) + return detailed_releases + elif selection == 'n': + cli_ui.info("Skipped - not using Blu-ray.com information") + return [] + else: + try: + selected_idx = int(selection) + + if 1 <= selected_idx <= len(matching_releases): + selected_release = matching_releases[selected_idx - 1] + cli_ui.info(f"Selected: {selected_release['title']} - {selected_release['country']} - {selected_release['publisher']}") + region_code = map_country_to_region_code(selected_release['country']) + meta['region'] = region_code + meta['distributor'] = selected_release['publisher'].upper() + meta['release_url'] = selected_release['url'] + cli_ui.info(f"Set region code to: {region_code}, distributor to: {selected_release['publisher'].upper()}") + + if meta.get('use_bluray_images', False): + console.print("[yellow]Fetching release details to get cover images...[/yellow]") + selected_release = await fetch_release_details(selected_release, meta) + + if 'cover_images' in selected_release and selected_release['cover_images']: + meta['cover_images'] = selected_release['cover_images'] + await download_cover_images(meta) + + return [selected_release] + else: + cli_ui.warning(f"Invalid selection: {selected_idx}. Must be between 1 and {len(matching_releases)}") + except ValueError: + cli_ui.warning(f"Invalid input: '{selection}'. Please enter a number, 'a', or 'n'") + + except (KeyboardInterrupt, EOFError): + raise SystemExit("Selection cancelled by user") + else: + console.print("[yellow]Unattended mode - selecting all releases") + detailed_releases = await process_all_releases(matching_releases, meta) + return detailed_releases + + imdb_id = meta.get('imdb_id', '0000000') + release_count = len(matching_releases) + debug_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/bluray_results_tt{imdb_id}_{release_count}releases.json" + + # always save a file in case the existing results are invalid + try: + with open(debug_filename, "w", encoding="utf-8") as f: + json.dump({ + "movie": { + "title": meta.get("title", "Unknown"), + "imdb_id": f"tt{meta.get('imdb_id', '0000000'):07d}" + }, + "matching_releases": matching_releases + }, f, indent=2) + if meta['debug']: + console.print(f"[dim]Saved results to {debug_filename}[/dim]") + except Exception as e: + console.print(f"[dim]Could not save debug results: {str(e)}[/dim]") + + return matching_releases + + +async def parse_release_details(response_text, release, meta): + try: + soup = BeautifulSoup(response_text, 'lxml') + specs_td = soup.find('td', width="228px", style=lambda s: s and 'font-size: 12px' in s) + + if not specs_td: + console.print("[red]Could not find specs section on the release page[/red]") + return release + + specs = { + 'video': {}, + 'audio': [], + 'subtitles': [], + 'discs': {}, + 'playback': {}, + } + + # Parse video section + video_section = extract_section(specs_td, 'Video') + if video_section: + codec_match = re.search(r'Codec: ([^<\n]+)', video_section) + if codec_match: + specs['video']['codec'] = codec_match.group(1).strip() + if meta['debug']: + console.print(f"[blue]Video Codec: {specs['video']['codec']}[/blue]") + + resolution_match = re.search(r'Resolution: ([^<\n]+)', video_section) + if resolution_match: + specs['video']['resolution'] = resolution_match.group(1).strip() + if meta['debug']: + console.print(f"[blue]Resolution: {specs['video']['resolution']}[/blue]") + + # Parse audio section + audio_section = extract_section(specs_td, 'Audio') + if audio_section: + audio_div = specs_td.find('div', id='longaudio') + if not audio_div: + audio_div = specs_td.find('div', id='shortaudio') + if meta['debug']: + console.print("[dim]Using shortaudio because longaudio wasn't found[/dim]") + if audio_div: + audio_html = str(audio_div) + audio_html = re.sub(r'', '\n', audio_html) + audio_soup = BeautifulSoup(audio_html, 'lxml') + raw_text = audio_soup.get_text() + raw_lines = [line.strip() for line in raw_text.split('\n') if line.strip() and 'less' not in line] + + audio_lines = [] + i = 0 + while i < len(raw_lines): + current_line = raw_lines[i] + is_atmos = 'atmos' in current_line.lower() + + # If it's an Atmos track and there's a next line with the same language, combine them + if is_atmos and i + 1 < len(raw_lines): + next_line = raw_lines[i + 1] + current_lang = current_line.split(':', 1)[0].strip() if ':' in current_line else '' + next_lang = next_line.split(':', 1)[0].strip() if ':' in next_line else '' + + if current_lang and current_lang == next_lang: + # This is likely an Atmos track followed by its core track + # Combine them into a single entry + if 'Dolby Atmos' in current_line and ('Dolby Digital' in next_line or 'Dolby TrueHD' in next_line): + channel_info = "" + if '7.1' in next_line: + channel_info = "7.1" + elif '5.1' in next_line: + channel_info = "5.1" + if 'TrueHD' in next_line: + combined_track = f"{current_lang}: Dolby TrueHD Atmos {channel_info}" + else: + combined_track = f"{current_lang}: Dolby Atmos {channel_info}" + + audio_lines.append(combined_track) + i += 2 + continue + + if current_line.startswith("Note:"): + # This is a note for the previous track + if audio_lines: + audio_lines[-1] = f"{audio_lines[-1]} - {current_line}" + else: + # This is a new track + audio_lines.append(current_line) + + i += 1 + + specs['audio'] = audio_lines + if meta['debug']: + console.print(f"[blue]Audio Tracks: {len(audio_lines)} found[/blue]") + for track in audio_lines: + console.print(f"[dim] - {track}[/dim]") + + # Parse subtitle section + subtitle_section = extract_section(specs_td, 'Subtitles') + if subtitle_section: + subs_div = specs_td.find('div', id='longsubs') + if not subs_div: + subs_div = specs_td.find('div', id='shortsubs') + if meta['debug']: + console.print("[dim]Using shortsubs because longsubs wasn't found[/dim]") + if subs_div: + subtitle_text = subs_div.get_text().strip() + subtitle_text = re.sub(r'\s*\(less\)\s*', '', subtitle_text) + subtitles = [s.strip() for s in re.split(r',|\n', subtitle_text) if s.strip()] + specs['subtitles'] = subtitles + if meta['debug']: + console.print(f"[blue]Subtitles: {', '.join(subtitles)}[/blue]") + + # Parse disc section + disc_section = extract_section(specs_td, 'Discs') + if disc_section: + disc_type_match = re.search(r'(Blu-ray Disc|DVD|Ultra HD Blu-ray|4K Ultra HD)', disc_section) + if disc_type_match: + specs['discs']['type'] = disc_type_match.group(1).strip() + if meta['debug']: + console.print(f"[blue]Disc Type: {specs['discs']['type']}[/blue]") + + disc_count_match = re.search(r'Single disc \(1 ([^)]+)\)|(One|Two|Three|Four|Five|\d+)[ -]disc set(?:\s*\(([^)]+)\))?', disc_section) + if meta['debug']: + console.print(f"[dim]Disc Count Match: {disc_count_match}[/dim]") + if disc_count_match: + if disc_count_match.group(1): + specs['discs']['count'] = 1 + specs['discs']['format'] = disc_count_match.group(1).strip() + else: + disc_count = disc_count_match.group(2) + if disc_count.isdigit(): + specs['discs']['count'] = int(disc_count) + else: + number_map = {"One": 1, "Two": 2, "Three": 3, "Four": 4, "Five": 5} + specs['discs']['count'] = number_map.get(disc_count, 1) + + if disc_count_match.group(3): + bd_format_match = re.search(r'(\d+\s*BD-\d+|\d+\s*BD)', disc_count_match.group(3)) + if meta['debug']: + console.print(f"[dim]BD Format Match: {bd_format_match}[/dim]") + if bd_format_match: + specs['discs']['format'] = bd_format_match.group(1).strip() + else: + bd_match = re.search(r'(\d+\s*BD-\d+)', disc_count_match.group(3)) + if bd_match: + specs['discs']['format'] = bd_match.group(1).strip() + else: + specs['discs']['format'] = "multiple discs" + else: + specs['discs']['format'] = "multiple discs" + + # Parse playback section + playback_section = extract_section(specs_td, 'Playback') + if playback_section: + region_match = re.search(r'(?:2K Blu-ray|4K Blu-ray|DVD): Region ([A-C])(?: \(([^)]+)\))?', playback_section) + if region_match: + specs['playback']['region'] = region_match.group(1).strip() + specs['playback']['region_notes'] = region_match.group(2).strip() if region_match.group(2) else "" + if meta['debug']: + console.print(f"[blue]Region: {specs['playback']['region']}[/blue]") + if specs['playback']['region_notes']: + if meta['debug']: + console.print(f"[dim]Region Notes: {specs['playback']['region_notes']}[/dim]") + + if meta.get('use_bluray_images', False): + cover_images = extract_cover_images(response_text) + if cover_images: + release['cover_images'] = cover_images + if meta['debug']: + console.print(f"[green]Found {len(cover_images)} cover images:[/green]") + for img_type, url in cover_images.items(): + console.print(f"[dim] - {img_type}: {url}[/dim]") + + release['specs'] = specs + if meta['debug']: + console.print(f"[green]Successfully parsed details for {release['title']}[/green]") + return release + + except Exception as e: + console.print(f"[red]Error parsing release details: {str(e)}[/red]") + console.print_exception() + return release + + +async def download_cover_images(meta): + if 'cover_images' not in meta or not meta['cover_images']: + console.print("[yellow]No cover images to download[/yellow]") + return False + + temp_dir = f"{meta['base_dir']}/tmp/{meta['uuid']}" + os.makedirs(temp_dir, exist_ok=True) + + reuploaded_images_path = os.path.join(meta['base_dir'], "tmp", meta['uuid'], "covers.json") + if os.path.exists(reuploaded_images_path): + try: + with open(reuploaded_images_path, 'r', encoding='utf-8') as f: + existing_covers = json.load(f) + + matching_release = False + if isinstance(existing_covers, list) and len(existing_covers) > 0: + for cover in existing_covers: + if cover.get('release_url') == meta.get('release_url'): + if meta['debug']: + console.print(f"[green]Found existing cover images for this release URL: {meta.get('release_url')}[/green]") + matching_release = True + return True + + if not matching_release: + if meta['debug']: + console.print(f"[yellow]Existing covers.json found but none match current release URL: {meta.get('release_url')}[/yellow]") + console.print("[yellow]Deleting outdated covers.json file[/yellow]") + os.remove(reuploaded_images_path) + + except Exception as e: + console.print(f"[red]Error reading covers.json: {str(e)}[/red]") + try: + os.remove(reuploaded_images_path) + if meta['debug']: + console.print("[yellow]Deleted potentially corrupted covers.json file[/yellow]") + except Exception as delete_error: + console.print(f"[red]Failed to delete corrupted covers.json: {str(delete_error)}[/red]") + + downloaded_images = {} + console.print("[blue]Downloading cover images...[/blue]") + + async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: + for img_type, url in meta['cover_images'].items(): + file_ext = os.path.splitext(url)[1] + local_filename = f"{temp_dir}/cover_{img_type}{file_ext}" + + try: + console.print(f"[dim]Downloading {img_type} cover from {url}[/dim]") + response = await client.get(url) + + if response.status_code == 200: + with open(local_filename, "wb") as f: + f.write(response.content) + downloaded_images[img_type] = local_filename + console.print(f"[green]✓[/green] Downloaded {img_type} cover to {local_filename}") + else: + console.print(f"[red]Failed to download {img_type} cover: HTTP {response.status_code}[/red]") + except Exception as e: + console.print(f"[red]Error downloading {img_type} cover: {str(e)}[/red]") + + if downloaded_images: + meta['downloaded_cover_images'] = downloaded_images + console.print(f"[green]Successfully downloaded {len(downloaded_images)} cover images[/green]") + return True + else: + console.print("[yellow]No cover images were downloaded[/yellow]") + return False + + +def extract_cover_images(html_content): + cover_images = {} + soup = BeautifulSoup(html_content, 'lxml') + scripts = soup.find_all('script', string=lambda s: s and "$(document).ready" in s and "append(' 0: + end_pos = pos + len(ext) + break + + if end_pos: + return url[:end_pos] + return url + + +async def fetch_release_details(release, meta): + release_url = release['url'] + release_id = release.get('release_id', '0000000') + debug_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/debug_release_{release_id}.html" + if meta['debug']: + console.print(f"[yellow]Fetching details for: {release['title']} - {release_url}[/yellow]") + + try: + import os + if os.path.exists(debug_filename): + if meta['debug']: + console.print(f"[green]Found existing debug file for release ID {release_id}[/green]") + with open(debug_filename, "r", encoding="utf-8") as f: + response_text = f.read() + + if response_text and "No index" not in response_text: + return await parse_release_details(response_text, release, meta) + else: + console.print("[yellow]Cached file exists but appears to be invalid, will fetch fresh data[/yellow]") + except Exception as e: + console.print(f"[yellow]Error reading cached file: {str(e)}[/yellow]") + + # If we're here, we need to make a request + delay = random.uniform(2, 4) + if meta['debug']: + console.print(f"[dim]Waiting {delay:.2f} seconds before request...[/dim]") + await asyncio.sleep(delay) + + headers = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Referer": "https://www.blu-ray.com/movies/", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin" + } + + max_retries = 2 + retry_count = 0 + backoff_time = 3.0 + response_text = None + + while retry_count <= max_retries: + try: + if meta['debug']: + console.print(f"[yellow]Sending request to {release_url} (attempt {retry_count + 1}/{max_retries + 1})...[/yellow]") + + async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client: + response = await client.get(release_url, headers=headers) + + if response.status_code == 200 and "No index" not in response.text: + response_text = response.text + + try: + release_id = release.get('release_id', '0000000') + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/debug_release_{release_id}.html", "w", encoding="utf-8") as f: + f.write(response_text) + if meta['debug']: + console.print(f"[dim]Saved release page to debug_release_{release_id}.html[/dim]") + except Exception as e: + console.print(f"[dim]Could not save debug file: {str(e)}[/dim]") + + break + + elif "No index" in response.text: + console.print(f"[red]Blocked by blu-ray.com when accessing {release_url} (attempt {retry_count + 1}/{max_retries + 1})[/red]") + if retry_count < 2: + backoff_time *= 2 + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on this release[/red]") + break + else: + console.print(f"[red]Failed to get release details, status code: {response.status_code} (attempt {retry_count + 1}/{max_retries + 1})[/red]") + if retry_count < max_retries: + backoff_time *= 2 + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on this release[/red]") + break + + except httpx.RequestError as e: + console.print(f"[red]HTTP request error when accessing {release_url} (attempt {retry_count + 1}/{max_retries + 1}): {str(e)}[/red]") + if retry_count < max_retries: + backoff_time *= 2 + console.print(f"[yellow]Retrying in {backoff_time:.1f} seconds...[/yellow]") + await asyncio.sleep(backoff_time) + retry_count += 1 + else: + console.print("[red]Maximum retries reached, giving up on this release[/red]") + break + + if not response_text: + console.print("[red]Failed to retrieve release details after all attempts[/red]") + return release + else: + release = await parse_release_details(response_text, release, meta) + return release + + +def extract_section(specs_td, section_title): + section_span = specs_td.find('span', class_='subheading', string=section_title) + if not section_span: + return None + + section_content = [] + current_element = section_span.next_sibling + + while current_element: + if current_element.name == 'span' and 'subheading' in current_element.get('class', []): + break + + if isinstance(current_element, str): + section_content.append(current_element) + elif current_element.name: + section_content.append(current_element.get_text()) + + current_element = current_element.next_sibling + + return ''.join(section_content) + + +async def process_all_releases(releases, meta): + if not releases: + return [] + + if meta['debug']: + console.print() + console.print("Processing Local Details") + console.print("----------------------------") + + disc_count = len(meta.get('discs', [])) + if meta['debug']: + console.print(f"[dim]Local disc count from meta: {disc_count}") + + meta_video_specs = {} + meta_audio_specs = [] + meta_subtitles = [] + + if disc_count > 0 and 'discs' in meta and 'bdinfo' in meta['discs'][0]: + bdinfo = meta['discs'][0]['bdinfo'] + + if 'video' in bdinfo and bdinfo['video']: + meta_video_specs = bdinfo['video'][0] + codec = meta_video_specs.get('codec', '') + resolution = meta_video_specs.get('res', '') + if meta['debug']: + console.print(f"[dim]Local video: {codec} {resolution}") + + if 'audio' in bdinfo and bdinfo['audio']: + meta_audio_specs = bdinfo['audio'] + for track in meta_audio_specs: + if meta['debug']: + console.print(f"[dim]Local audio: {track.get('language', '')} {track.get('codec', '')} {track.get('channels', '')} {track.get('bitrate', '')}") + + bd_summary_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt" + filtered_languages = [] + meta_subtitles = [] # Initialize here so it's clear we're creating it + + if os.path.exists(bd_summary_path): + if meta['debug']: + console.print(f"[blue]Opening BD_SUMMARY file: {bd_summary_path}[/blue]") + console.print("[dim]Stripping extremely small subtitle tracks from bdinfo[/dim]") + try: + with open(bd_summary_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + # Parse the subtitles section + for line in lines: + line = line.strip() + subtitle_line = None + if line.startswith("Subtitle:"): + subtitle_line = line + elif line.startswith("* Subtitle:"): + subtitle_line = line[2:].strip() + + if subtitle_line: + # Extract the subtitle language and bitrate + subtitle_match = re.match(r"Subtitle:\s+(\w+)\s+/\s+([\d.]+)\s+kbps", subtitle_line) + if subtitle_match: + language = subtitle_match.group(1) + bitrate = float(subtitle_match.group(2)) + + # Keep subtitles with a bitrate >= 1.0 kbps + if bitrate >= 1.0: + filtered_languages.append(language.lower()) + meta_subtitles.append(language) # Add to meta_subtitles directly + if meta['debug']: + console.print(f"[green]✓ Keeping subtitle: {language} ({bitrate} kbps)[/green]") + else: + if meta['debug']: + console.print(f"[red]✗ Discarding subtitle due to size: {language} ({bitrate} kbps)[/red]") + + if meta_subtitles: + if meta['debug']: + console.print(f"[blue]Added subtitle languages: {', '.join(meta_subtitles)}[/blue]") + else: + console.print("[yellow]No valid subtitles found to add.[/yellow]") + + except Exception as e: + console.print(f"[red]Error reading BD_SUMMARY file: {str(e)}[/red]") + else: + console.print(f"[red]BD_SUMMARY file not found: {bd_summary_path}[/red]") + + detailed_releases = [] + for idx, release in enumerate(releases, 1): + console.print(f"[cyan]Processing release {idx}/{len(releases)}: {release['title']} ({release['country']})") + detailed_release = await fetch_release_details(release, meta) + detailed_releases.append(detailed_release) + + if meta['debug']: + console.print() + cli_ui.info_section("Processing Complete") + cli_ui.info(f"Successfully processed {len(detailed_releases)} releases") + + logs = [] # Initialize a list to store logs for each release + + def log_and_print(message, log_list): + if meta['debug']: + console.print(message) + log_list.append(message) + + if detailed_releases: + scored_releases = [] + for idx, release in enumerate(detailed_releases, 1): + release_logs = [] + if meta['debug']: + console.print(f"\n[bold blue]=== Release {idx}/{len(detailed_releases)}: {release['title']} ({release['country']}) ===[/bold blue]") + log_and_print(f"[blue]Release URL: {release['url']}[/blue]", release_logs) + score = 100.0 + + if 'specs' in release: + specs = release['specs'] + + specs_missing = False + # Check for completeness of data (penalty for missing info) + if not specs.get('video', {}): + score -= 5 # Missing video info + specs_missing = True + log_and_print("[red]✗[/red] Missing video info", release_logs) + log_and_print("[dim]Penalty for missing video info: 5.0[/dim]", release_logs) + if not specs.get('audio', []): + score -= 5 # Missing audio info + specs_missing = True + log_and_print("[red]✗[/red] Missing audio info", release_logs) + log_and_print("[dim]Penalty for missing audio info: 5.0[/dim]", release_logs) + if meta_subtitles and not specs.get('subtitles', []): + score -= 5 # Missing subtitle info when bdinfo has subtitles + specs_missing = True + log_and_print("[red]✗[/red] Missing subtitle info", release_logs) + log_and_print("[dim]Penalty for missing subtitle info: 5.0[/dim]", release_logs) + if not specs.get('discs', {}): + score -= 5 # Missing disc info + specs_missing = True + log_and_print("[red]✗[/red] Missing disc info", release_logs) + log_and_print("[dim]Penalty for missing disc info: 5.0[/dim]", release_logs) + + # Disc format check + if 'discs' in specs and 'format' in specs['discs'] and 'discs' in meta and 'bdinfo' in meta['discs'][0]: + release_format = specs['discs']['format'].lower() + disc_size_gb = meta['discs'][0]['bdinfo'].get('size', 0) + + expected_format = "" + if disc_size_gb < 25: + expected_format = "bd-25" + elif disc_size_gb < 50: + expected_format = "bd-50" + elif disc_size_gb < 66: + expected_format = "bd-66" + else: + expected_format = "bd-100" + + format_match = False + if release_format == "bd" or "bd" == release_format: + format_match = "generic" + log_and_print(f"[yellow]⚠[/yellow] Generic BD format found: {specs['discs']['format']} for size {disc_size_gb:.2f} GB", release_logs) + elif expected_format and expected_format in release_format: + format_match = True + log_and_print(f"[green]✓[/green] Disc format match: {specs['discs']['format']} matches size {disc_size_gb:.2f} GB", release_logs) + elif expected_format: + score -= 50 + log_and_print(f"[yellow]⚠[/yellow] Disc format mismatch: {specs['discs']['format']} vs expected {expected_format.upper()} (size: {disc_size_gb:.2f} GB)", release_logs) + if meta['debug']: + log_and_print("[dim]Penalty for disc format mismatch: 50.0[/dim]", release_logs) + + if format_match == "generic": + score -= 5 + if meta['debug']: + log_and_print("[dim]Reduced penalty for generic BD format: 5.0[/dim]", release_logs) + + # Video format checks + if 'video' in specs and meta_video_specs: + release_codec = specs['video'].get('codec', '').lower() + meta_codec = meta_video_specs.get('codec', '').lower() + + codec_match = False + if ('avc' in release_codec and 'avc' in meta_codec) or \ + ('h.264' in release_codec and ('avc' in meta_codec or 'h.264' in meta_codec)): + codec_match = True + log_and_print("[green]✓[/green] Video codec match: AVC/H.264", release_logs) + elif ('hevc' in release_codec and 'hevc' in meta_codec) or \ + ('h.265' in release_codec and ('hevc' in meta_codec or 'h.265' in meta_codec)): + codec_match = True + log_and_print("[green]✓[/green] Video codec match: HEVC/H.265", release_logs) + elif ('vc-1' in release_codec and 'vc-1' in meta_codec) or \ + ('vc1' in release_codec and 'vc1' in meta_codec): + codec_match = True + log_and_print("[green]✓[/green] Video codec match: VC-1", release_logs) + elif ('mpeg-2' in release_codec and 'mpeg-2' in meta_codec) or \ + ('mpeg2' in release_codec and 'mpeg2' in meta_codec): + codec_match = True + log_and_print("[green]✓[/green] Video codec match: MPEG-2", release_logs) + + if not codec_match: + score -= 80 + log_and_print(f"[red]✗[/red] Video codec mismatch: {release_codec} vs {meta_codec}", release_logs) + if meta['debug']: + log_and_print("[dim]Penalty for video codec mismatch 80.0[/dim]", release_logs) + + # Resolution match check + release_res = specs['video'].get('resolution', '').lower() + meta_res = meta_video_specs.get('res', '').lower() + + res_match = False + if '1080' in release_res and '1080' in meta_res: + res_match = True + log_and_print("[green]✓[/green] Resolution match: 1080p", release_logs) + elif ('2160' in release_res or '4k' in release_res) and ('2160' in meta_res or '4k' in meta_res): + res_match = True + log_and_print("[green]✓[/green] Resolution match: 4K/2160p", release_logs) + + if not res_match: + score -= 80 + log_and_print(f"[red]✗[/red] Resolution mismatch: {release_res} vs {meta_res}", release_logs) + if meta['debug']: + log_and_print("[dim]Penalty for resolution mismatch 80.0[/dim]", release_logs) + else: + score -= 5 + log_and_print("[yellow]?[/yellow] Cannot compare video formats", release_logs) + + # Audio track checks + if 'audio' in specs and meta_audio_specs: + audio_matches = 0 + partial_audio_matches = 0 + missing_audio_tracks = 0 + available_release_tracks = specs.get('audio', [])[:] + reduced_penalty_count = 0 + for meta_idx, meta_track in enumerate(meta_audio_specs): + meta_lang = meta_track.get('language', '').lower() + meta_format = meta_track.get('codec', '').lower().replace('audio', '') + meta_channels = meta_track.get('channels', '').lower().replace('audio', '') + meta_sample_rate = meta_track.get('sample_rate', '').lower() + meta_bit_depth = meta_track.get('bit_depth', '').lower() + meta_bitrate = meta_track.get('bitrate', '').lower() + + # Special handling for Atmos tracks + if meta_track.get('atmos_why_you_be_like_this', '').lower() == 'atmos' or 'atmos' in meta_channels: + if 'truehd' in meta_format: + meta_format = 'dolby truehd atmos' + elif 'dolby' in meta_format: + meta_format = 'dolby atmos' + if meta_channels.strip() in ['atmos audio', 'atmos', '']: + if meta_sample_rate in ['7.1', '5.1', '2.0', '1.0']: + meta_channels = meta_sample_rate + else: + meta_channels = '7.1' + + if 'khz' in meta_bitrate and 'khz' not in meta_sample_rate: + meta_sample_rate = meta_bitrate + meta_bitrate = "" + + if 'kbps' in meta_bit_depth: + bitrate_part = re.search(r'(\d+\s*kbps)', meta_bit_depth) + if bitrate_part: + meta_bitrate = bitrate_part.group(1) + bit_depth_part = re.search(r'(\d+)-bit', meta_bit_depth) + if bit_depth_part: + meta_bit_depth = bit_depth_part.group(1) + "-bit" + else: + meta_bit_depth = "" + + # Skip bit depth if it contains "DN -" (Dolby Digital Normalization) + if 'dn -' in meta_bit_depth: + meta_bit_depth = "" + + reduced_penalty = False + if meta_idx > 0 and meta_bitrate and "kbps" in meta_bitrate: + bitrate_value = int(meta_bitrate.replace("kbps", "").strip()) + if bitrate_value <= 258: + reduced_penalty = True + + best_match_score = 0 + best_match_core_score = 0 + best_match_idx = -1 + track_found = False + + for idx, release_track in enumerate(available_release_tracks): + release_track_lower = release_track.lower() + current_match_score = 0 + core_match_score = 0 + + lang_match = False + if meta_lang and meta_lang in release_track_lower: + lang_match = True + current_match_score += 1 + core_match_score += 1 + + if not lang_match: + continue + + format_match = False + if 'lpcm' in meta_format and ('pcm' in release_track_lower or 'lpcm' in release_track_lower): + format_match = True + current_match_score += 1 + core_match_score += 1 + elif 'dts-hd' in meta_format and 'dts-hd' in release_track_lower: + format_match = True + current_match_score += 1 + core_match_score += 1 + elif 'dts' in meta_format and 'dts' in release_track_lower: + format_match = True + current_match_score += 1 + core_match_score += 1 + elif 'dolby' in meta_format and 'dolby' in release_track_lower: + format_match = True + current_match_score += 1 + core_match_score += 1 + elif 'truehd' in meta_format and 'truehd' in release_track_lower: + format_match = True + current_match_score += 1 + core_match_score += 1 + elif 'atmos' in meta_format and 'atmos' in release_track_lower: + format_match = True + current_match_score += 1 + core_match_score += 1 + + channel_match = False + if meta_channels: + if '5.1' in meta_channels and '5.1' in release_track_lower: + channel_match = True + current_match_score += 1 + core_match_score += 1 + elif '7.1' in meta_channels and '7.1' in release_track_lower: + channel_match = True + current_match_score += 1 + core_match_score += 1 + elif '2.0' in meta_channels and '2.0' in release_track_lower: + channel_match = True + current_match_score += 1 + core_match_score += 1 + elif '2.0' in meta_channels and 'stereo' in release_track_lower: + channel_match = True + current_match_score += 1 + core_match_score += 1 + elif '1.0' in meta_channels and '1.0' in release_track_lower: + channel_match = True + current_match_score += 1 + core_match_score += 1 + elif '1.0' in meta_channels and 'mono' in release_track_lower: + channel_match = True + current_match_score += 1 + core_match_score += 1 + elif '2.0' in meta_channels and 'mono' in release_track_lower: + channel_match = False + elif '1.0' in meta_channels and ('2.0' in release_track_lower or 'stereo' in release_track_lower): + channel_match = False + + # Check sample rate and bit depth in the release track (may be in notes) + if meta_sample_rate: + sample_rate_str = meta_sample_rate.replace(' ', '').lower() + if sample_rate_str in release_track_lower.replace(' ', ''): + current_match_score += 1 + elif "note:" in release_track_lower and sample_rate_str in release_track_lower: + current_match_score += 1 + + if meta_bit_depth and meta_bit_depth != "": + bit_depth_str = meta_bit_depth.lower() + if bit_depth_str in release_track_lower: + current_match_score += 1 + elif bit_depth_str.replace('-', '') in release_track_lower.replace(' ', ''): + current_match_score += 1 + elif "note:" in release_track_lower and bit_depth_str.replace('-', '') in release_track_lower.replace(' ', ''): + current_match_score += 1 + + if meta_bitrate and meta_bitrate != "": + bitrate_str = meta_bitrate.lower() + if bitrate_str in release_track_lower: + current_match_score += 1 + elif "note:" in release_track_lower and bitrate_str in release_track_lower: + current_match_score += 1 + + if current_match_score > best_match_score: + best_match_score = current_match_score + best_match_core_score = core_match_score + best_match_idx = idx + + if lang_match and (format_match or channel_match): + track_found = True + + if track_found and best_match_idx >= 0: + # Calculate matches based on core fields (language, format, channels) + # Maximum core score: language (1) + format (1) + channels (1) = 3 + core_match_quality = best_match_core_score / 3.0 + matched_track = available_release_tracks[best_match_idx] + + if core_match_quality >= 1: + audio_matches += 1 + log_and_print(f"[green]✓[/green] Found good match for {meta_lang} {meta_format} {meta_channels} track: '{matched_track}' (match quality: 100%)", release_logs) + else: + partial_audio_matches += 1 + percent = int(core_match_quality * 100) + log_and_print(f"[yellow]⚠[/yellow] Found partial match for {meta_lang} {meta_format} {meta_channels} track: '{matched_track}' (match quality: {percent}%)", release_logs) + + available_release_tracks.pop(best_match_idx) + + else: + missing_audio_tracks += 1 + if reduced_penalty: + reduced_penalty_count += 1 + log_and_print(f"[red]✗[/red] No match found for {meta_lang} {meta_format} {meta_channels} track (Low bitrate, half penalty)", release_logs) + else: + log_and_print(f"[red]✗[/red] No match found for {meta_lang} {meta_format} {meta_channels} {meta_bitrate} track", release_logs) + + total_tracks = len(meta_audio_specs) + if total_tracks > 0: + full_match_percentage = (audio_matches / total_tracks) * 100 + partial_match_percentage = (partial_audio_matches / total_tracks) * 100 + + if audio_matches == total_tracks: + audio_penalty = 0 + # Single bdinfo track penalty adjustment + elif total_tracks == 1: + if audio_matches == 1: + audio_penalty = 0 + elif partial_audio_matches == 1: + audio_penalty = 5.0 + else: + audio_penalty = 10.0 + # Multiple bdinfo tracks penalty adjustment + else: + audio_penalty = 0 + audio_penalty += partial_audio_matches * 2.5 + missing_tracks = total_tracks - (audio_matches + partial_audio_matches) + normal_missing = missing_audio_tracks - reduced_penalty_count + audio_penalty += normal_missing * 5.0 + audio_penalty += reduced_penalty_count * 2.5 + + if meta['debug']: + log_and_print(f"[dim]Audio penalty: {audio_penalty:.1f}[/dim]", release_logs) + score -= audio_penalty + + if audio_matches > 0: + log_and_print(f"[green]✓[/green] Audio tracks with good matches: {audio_matches}/{total_tracks} ({full_match_percentage:.1f}% of tracks)", release_logs) + if partial_audio_matches > 0: + log_and_print(f"[yellow]⚠[/yellow] Audio tracks with partial matches: {partial_audio_matches}/{total_tracks} ({partial_match_percentage:.1f}% of tracks)", release_logs) + elif partial_audio_matches > 0: + log_and_print(f"[yellow]⚠[/yellow] There were only partial audio track matches: {partial_audio_matches}/{total_tracks}", release_logs) + else: + log_and_print("[red]✗[/red] No audio tracks match!", release_logs) + + extra_audio_tracks = [] + if available_release_tracks: + for release_track in available_release_tracks: + extra_audio_tracks.append(release_track) + log_and_print(f"[yellow]⚠[/yellow] Release has extra audio track not in BDInfo: {release_track}", release_logs) + + if extra_audio_tracks: + extra_penalty = len(extra_audio_tracks * 5) + score -= extra_penalty + log_and_print(f"[red]-[/red] Found {len(extra_audio_tracks)} additional audio tracks in release not in BDInfo", release_logs) + if meta['debug']: + log_and_print(f"[dim]Extra audio tracks penalty: {extra_penalty:.1f} points[/dim]", release_logs) + + else: + score -= 5 + log_and_print("[yellow]?[/yellow] Cannot compare audio tracks", release_logs) + + # Subtitle checks + if 'subtitles' in specs and meta_subtitles: + sub_matches = 0 + missing_subs = 0 + available_release_subs = specs.get('subtitles', [])[:] + + for meta_sub in meta_subtitles: + meta_sub_lower = meta_sub.lower() + sub_found = False + matched_idx = -1 + + for idx, release_sub in enumerate(available_release_subs): + release_sub_lower = release_sub.lower() + if meta_sub_lower in release_sub_lower or release_sub_lower in meta_sub_lower: + sub_found = True + matched_idx = idx + break + + if sub_found and matched_idx >= 0: + matched_sub = available_release_subs[matched_idx] + sub_matches += 1 + log_and_print(f"[green]✓[/green] Subtitle match found: {meta_sub} -> {matched_sub}", release_logs) + available_release_subs.pop(matched_idx) + else: + missing_subs += 1 + log_and_print(f"[red]✗[/red] No match found for subtitle: {meta_sub}", release_logs) + + total_subs = len(meta_subtitles) + if total_subs > 0: + match_percentage = (sub_matches / total_subs) * 100 + missing_tracks = total_subs - sub_matches + if total_subs == 1 and sub_matches == 0: + sub_penalty = 10.0 + else: + sub_penalty = 5.0 * missing_tracks + if meta['debug']: + log_and_print(f"[dim]Subtitle penalty: {sub_penalty:.1f}[/dim]", release_logs) + score -= sub_penalty + + if sub_matches > 0: + log_and_print(f"[green]✓[/green] Subtitle matches: {sub_matches}/{total_subs} ({match_percentage:.1f}%)", release_logs) + else: + log_and_print("[red]✗[/red] No subtitle tracks match!", release_logs) + + extra_subtitles = [] + if available_release_subs: + for release_sub in available_release_subs: + extra_subtitles.append(release_sub) + log_and_print(f"[yellow]⚠[/yellow] Release has extra subtitle not in BDInfo: {release_sub}", release_logs) + + if extra_subtitles: + extra_penalty = len(extra_subtitles * 5) + score -= extra_penalty + log_and_print(f"[red]-[/red] Found {len(extra_subtitles)} additional subtitles in release not in BDInfo", release_logs) + if meta['debug']: + log_and_print(f"[dim]Extra subtitles penalty: {extra_penalty:.1f} points[/dim]", release_logs) + + else: + score -= 5 + log_and_print("[yellow]?[/yellow] Cannot compare subtitles", release_logs) + else: + score -= 80 + log_and_print("[red]✗[/red] No specifications available for this release", release_logs) + + log_and_print(f"[blue]Final score: {score:.1f}/100 for {release['title']} ({release['country']})[/blue]", release_logs) + log_and_print("", release_logs) + scored_releases.append((score, release)) + logs.append((release, release_logs)) + + scored_releases.sort(reverse=True, key=lambda x: x[0]) + + if scored_releases: + bluray_score = meta.get('bluray_score', 100) + bluray_single_score = meta.get('bluray_single_score', 100) + best_score, best_release = scored_releases[0] + close_matches = [release for score, release in scored_releases if best_score - score <= 40] + + if len(scored_releases) == 1 and best_score == 100: + cli_ui.info(f"Single perfect match found: {best_release['title']} ({best_release['country']}) with score {best_score:.1f}/100") + region_code = map_country_to_region_code(best_release['country']) + meta['region'] = region_code + meta['distributor'] = best_release['publisher'].upper() + meta['release_url'] = best_release['url'] + if 'cover_images' in best_release: + meta['cover_images'] = best_release['cover_images'] + await download_cover_images(meta) + console.print(f"[yellow]Set region code to: {region_code}, distributor to: {best_release['publisher'].upper()}") + + elif len(scored_releases) == 1: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + cli_ui.info(f"Single match found: {close_matches[0]['title']} ({close_matches[0]['country']}) with score {best_score:.1f}/100") + while True: + user_input = input("Do you want to use this release? (y/n): ").strip().lower() + try: + if user_input == 'y': + region_code = map_country_to_region_code(close_matches[0]['country']) + meta['region'] = region_code + meta['distributor'] = close_matches[0]['publisher'].upper() + meta['release_url'] = close_matches[0]['url'] + if 'cover_images' in close_matches[0]: + meta['cover_images'] = close_matches[0]['cover_images'] + await download_cover_images(meta) + console.print(f"[yellow]Set region code to: {region_code}, distributor to: {close_matches[0]['publisher'].upper()}") + break + elif user_input == 'n': + cli_ui.warning("No release selected.") + detailed_releases = [] + break + else: + console.print("[red]Invalid input. Please enter 'y' or 'n'.[/red]") + except ValueError: + console.print("[red]Invalid input. Please enter 'y' or 'n'.[/red]") + except KeyboardInterrupt: + console.print("[red]Operation cancelled.[/red]") + break + elif best_score > bluray_single_score: + cli_ui.info(f"Best match: {best_release['title']} ({best_release['country']}) with score {best_score:.1f}/100") + region_code = map_country_to_region_code(best_release['country']) + meta['region'] = region_code + meta['distributor'] = best_release['publisher'].upper() + meta['release_url'] = best_release['url'] + if 'cover_images' in best_release: + meta['cover_images'] = best_release['cover_images'] + await download_cover_images(meta) + console.print(f"[yellow]Set region code to: {region_code}, distributor to: {best_release['publisher'].upper()}") + else: + cli_ui.warning(f"No suitable release found. Best match was {best_release['title']} ({best_release['country']}) with score {best_score:.1f}/100") + detailed_releases = [] + + elif len(close_matches) > 1: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended-confirm', False)): + console.print("[yellow]Multiple releases are within 40 points of the best match. Please confirm which release to use:[/yellow]") + if format_match == "generic": + console.print("[red]Note: Generic BD format found, please confirm the release.[/red]") + if specs_missing: + console.print("[red]Note: Missing specs in release, please confirm the release.[/red]") + for idx, release in enumerate(close_matches, 1): + score = next(score for score, r in scored_releases if r == release) + console.print(f"{idx}. [blue]{release['title']} ({release['country']})[/blue] - Score: {score:.1f}/100") + + while True: + console.print("Enter the number of the release to use, 'p' to print logs for a release, or 'n' to skip:") + user_input = input("Selection: ").strip().lower() + if user_input == 'n': + cli_ui.warning("No release selected.") + detailed_releases = [] + break + elif user_input == 'p': + try: + release_idx = int(input(f"Enter the release number (1-{len(close_matches)}) to print logs: ").strip()) + if 1 <= release_idx <= len(close_matches): + selected_release = close_matches[release_idx - 1] + for logged_release, release_logs in logs: + if logged_release == selected_release: + console.print(f"[yellow]Logs for release: {logged_release['title']} ({logged_release['country']})[/yellow]") + for log in release_logs: + console.print(log) + break + else: + console.print(f"[red]Invalid selection. Please enter a number between 1 and {len(close_matches)}.[/red]") + except ValueError: + console.print("[red]Invalid input. Please enter a valid number.[/red]") + except KeyboardInterrupt: + console.print("[red]Operation cancelled.[/red]") + break + else: + try: + selected_idx = int(user_input) + if 1 <= selected_idx <= len(close_matches): + selected_release = close_matches[selected_idx - 1] + cli_ui.info(f"Selected: {selected_release['title']} ({selected_release['country']})") + region_code = map_country_to_region_code(selected_release['country']) + meta['region'] = region_code + meta['distributor'] = selected_release['publisher'].upper() + meta['release_url'] = selected_release['url'] + if 'cover_images' in selected_release: + meta['cover_images'] = selected_release['cover_images'] + await download_cover_images(meta) + console.print(f"[yellow]Set region code to: {region_code}, distributor to: {selected_release['publisher'].upper()}[/yellow]") + break + else: + console.print(f"[red]Invalid selection. Please enter a number between 1 and {len(close_matches)}.[/red]") + except ValueError: + console.print("[red]Invalid input. Please enter a number or 'n'.[/red]") + except KeyboardInterrupt: + console.print("[red]Operation cancelled.[/red]") + break + elif best_score > bluray_score: + cli_ui.info(f"Best match: {best_release['title']} ({best_release['country']}) with score {best_score:.1f}/100") + region_code = map_country_to_region_code(best_release['country']) + meta['region'] = region_code + meta['distributor'] = best_release['publisher'].upper() + meta['release_url'] = best_release['url'] + if 'cover_images' in best_release: + meta['cover_images'] = best_release['cover_images'] + await download_cover_images(meta) + console.print(f"[yellow]Set region code to: {region_code}, distributor to: {best_release['publisher'].upper()}[/yellow]") + else: + cli_ui.warning(f"No suitable release found. Best match was {best_release['title']} ({best_release['country']}) with score {best_score:.1f}/100") + detailed_releases = [] + + else: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended-confirm', False)): + console.print("[red]This is the probably the best match, but it is not a perfect match.[/red]") + console.print("[yellow]All other releases have a score at least 40 points lower.") + for logged_release, release_logs in logs: + if logged_release == best_release: + console.print(f"[yellow]Logs for release: {logged_release['title']} ({logged_release['country']})[/yellow]") + for log in release_logs: + console.print(log) + while True: + user_input = input("Do you want to use this release? (y/n): ").strip().lower() + try: + if user_input == 'y': + region_code = map_country_to_region_code(best_release['country']) + meta['region'] = region_code + meta['distributor'] = best_release['publisher'].upper() + meta['release_url'] = best_release['url'] + if 'cover_images' in best_release: + meta['cover_images'] = best_release['cover_images'] + await download_cover_images(meta) + console.print(f"[yellow]Set region code to: {region_code}, distributor to: {best_release['publisher'].upper()}[/yellow]") + break + elif user_input == 'n': + cli_ui.warning("No release selected.") + detailed_releases = [] + break + else: + console.print("[red]Invalid input. Please enter 'y' or 'n'.[/red]") + except ValueError: + console.print("[red]Invalid input. Please enter 'y' or 'n'.[/red]") + except KeyboardInterrupt: + console.print("[red]Operation cancelled.[/red]") + break + elif best_score > bluray_score: + cli_ui.info(f"Best match: {best_release['title']} ({best_release['country']}) with score {best_score:.1f}/100") + region_code = map_country_to_region_code(best_release['country']) + meta['region'] = region_code + meta['distributor'] = best_release['publisher'].upper() + meta['release_url'] = best_release['url'] + if 'cover_images' in best_release: + meta['cover_images'] = best_release['cover_images'] + await download_cover_images(meta) + console.print(f"[yellow]Set region code to: {region_code}, distributor to: {best_release['publisher'].upper()}[/yellow]") + else: + cli_ui.warning(f"No suitable release found. Best match was {best_release['title']} ({best_release['country']}) with score {best_score:.1f}/100") + detailed_releases = [] + + return detailed_releases + + +def map_country_to_region_code(country_name): + country_map = { + "Afghanistan": "AFG", + "Albania": "ALB", + "Algeria": "ALG", + "Andorra": "AND", + "Angola": "ANG", + "Argentina": "ARG", + "Armenia": "ARM", + "Aruba": "ARU", + "Australia": "AUS", + "Austria": "AUT", + "Azerbaijan": "AZE", + "Bahamas": "BAH", + "Bahrain": "BHR", + "Bangladesh": "BAN", + "Barbados": "BRB", + "Belarus": "BLR", + "Belgium": "BEL", + "Belize": "BLZ", + "Benin": "BEN", + "Bermuda": "BER", + "Bhutan": "BHU", + "Bolivia": "BOL", + "Bosnia and Herzegovina": "BIH", + "Botswana": "BOT", + "Brazil": "BRA", + "British Virgin Islands": "VGB", + "Brunei": "BRU", + "Burkina Faso": "BFA", + "Burundi": "BDI", + "Cambodia": "CAM", + "Cameroon": "CMR", + "Canada": "CAN", + "Cape Verde": "CPV", + "Cayman Islands": "CAY", + "Central African Republic": "CTA", + "Chad": "CHA", + "Chile": "CHI", + "China": "CHN", + "Colombia": "COL", + "Comoros": "COM", + "Congo": "CGO", + "Cook Islands": "COK", + "Costa Rica": "CRC", + "Croatia": "CRO", + "Cuba": "CUB", + "Cyprus": "CYP", + "Dominican Republic": "DOM", + "Ecuador": "ECU", + "Egypt": "EGY", + "El Salvador": "SLV", + "Equatorial Guinea": "EQG", + "Eritrea": "ERI", + "Ethiopia": "ETH", + "Fiji": "FIJ", + "France": "FRA", + "Gabon": "GAB", + "Gambia": "GAM", + "Georgia": "GEO", + "Germany": "GER", + "Ghana": "GHA", + "Greece": "GRE", + "Grenada": "GRN", + "Guatemala": "GUA", + "Guinea": "GUI", + "Guyana": "GUY", + "Haiti": "HAI", + "Honduras": "HON", + "Hong Kong": "HKG", + "Hungary": "HUN", + "Iceland": "ISL", + "India": "IND", + "Indonesia": "IDN", + "Iran": "IRN", + "Iraq": "IRQ", + "Ireland": "IRL", + "Israel": "ISR", + "Italy": "ITA", + "Jamaica": "JAM", + "Japan": "JPN", + "Jordan": "JOR", + "Kazakhstan": "KAZ", + "Kenya": "KEN", + "Kuwait": "KUW", + "Kyrgyzstan": "KGZ", + "Laos": "LAO", + "Lebanon": "LBN", + "Liberia": "LBR", + "Libya": "LBY", + "Liechtenstein": "LIE", + "Luxembourg": "LUX", + "Macau": "MAC", + "Madagascar": "MAD", + "Malaysia": "MAS", + "Malta": "MLT", + "Mexico": "MEX", + "Monaco": "MON", + "Mongolia": "MNG", + "Morocco": "MAR", + "Mozambique": "MOZ", + "Namibia": "NAM", + "Nepal": "NEP", + "Netherlands": "NLD", + "New Zealand": "NZL", + "Nicaragua": "NCA", + "Niger": "NIG", + "North Korea": "PRK", + "North Macedonia": "MKD", + "Norway": "NOR", + "Oman": "OMA", + "Pakistan": "PAK", + "Panama": "PAN", + "Papua New Guinea": "PNG", + "Paraguay": "PAR", + "Peru": "PER", + "Philippines": "PHI", + "Poland": "POL", + "Portugal": "POR", + "Puerto Rico": "PUR", + "Qatar": "QAT", + "Romania": "ROU", + "Russia": "RUS", + "Rwanda": "RWA", + "Saint Lucia": "LCA", + "Samoa": "SAM", + "San Marino": "SMR", + "Saudi Arabia": "KSA", + "Senegal": "SEN", + "Serbia": "SRB", + "Singapore": "SIN", + "South Africa": "RSA", + "South Korea": "KOR", + "Spain": "ESP", + "Sri Lanka": "LKA", + "Sudan": "SDN", + "Suriname": "SUR", + "Switzerland": "SUI", + "Syria": "SYR", + "Chinese Taipei": "TWN", + "Tajikistan": "TJK", + "Tanzania": "TAN", + "Thailand": "THA", + "Trinidad and Tobago": "TRI", + "Tunisia": "TUN", + "Turkey": "TUR", + "Uganda": "UGA", + "Ukraine": "UKR", + "United Arab Emirates": "UAE", + "United Kingdom": "GBR", + "United States": "USA", + "Uruguay": "URU", + "Uzbekistan": "UZB", + "Venezuela": "VEN", + "Vietnam": "VIE", + "Zambia": "ZAM", + "Zimbabwe": "ZIM", + } + + region_code = country_map.get(country_name) + if not region_code: + region_code = None + + return region_code diff --git a/src/btnid.py b/src/btnid.py new file mode 100644 index 000000000..5b1c15edf --- /dev/null +++ b/src/btnid.py @@ -0,0 +1,203 @@ +import httpx +import uuid +from src.bbcode import BBCODE +from src.console import console + + +async def generate_guid(): + return str(uuid.uuid4()) + + +async def get_btn_torrents(btn_api, btn_id, meta): + if meta['debug']: + print("Fetching BTN data...") + post_query_url = "https://api.broadcasthe.net/" + post_data = { + "jsonrpc": "2.0", + "id": (await generate_guid())[:8], + "method": "getTorrentsSearch", + "params": [ + btn_api, + {"id": btn_id}, + 50 + ] + } + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(post_query_url, headers=headers, json=post_data, timeout=10) + response.raise_for_status() + try: + data = response.json() + except ValueError as e: + print(f"[ERROR] Failed to parse BTN response as JSON: {e}") + print(f"Response content: {response.text[:200]}...") + return meta + except Exception as e: + print(f"[ERROR] Failed to fetch BTN data: {e}") + return meta + + if not data or not isinstance(data, dict): + print("[ERROR] BTN API response is empty or invalid.") + return meta + + if "result" in data and "torrents" in data["result"]: + torrents = data["result"]["torrents"] + first_torrent = next(iter(torrents.values()), None) + if first_torrent: + imdb_id = first_torrent.get("ImdbID") + tvdb_id = first_torrent.get("TvdbID") + + if imdb_id and imdb_id != "0": + meta["imdb_id"] = int(imdb_id) + + if tvdb_id and tvdb_id != "0": + meta["tvdb_id"] = int(tvdb_id) + + if meta.get("imdb_id") or meta.get("tvdb_id"): + console.print(f"[green]Found BTN IDs: IMDb={meta.get('imdb_id')}, TVDb={meta.get('tvdb_id')}") + + return meta + if meta['debug']: + console.print("[red]No IMDb or TVDb ID found.") + return meta + + +async def get_bhd_torrents(bhd_api, bhd_rss_key, meta, only_id=False, info_hash=None, filename=None, foldername=None, torrent_id=None): + if meta['debug']: + print("Fetching BHD data...") + post_query_url = f"https://beyond-hd.me/api/torrents/{bhd_api}" + + if torrent_id is not None: + post_data = { + "action": "details", + "torrent_id": torrent_id, + } + else: + post_data = { + "action": "search", + "rsskey": bhd_rss_key, + } + + if info_hash: + post_data["info_hash"] = info_hash + + if filename: + post_data["file_name"] = filename + + if foldername: + post_data["folder_name"] = foldername + + headers = {"Content-Type": "application/json"} + + try: + async with httpx.AsyncClient() as client: + response = await client.post(post_query_url, headers=headers, json=post_data, timeout=10) + response.raise_for_status() + try: + data = response.json() + except ValueError as e: + print(f"[ERROR] Failed to parse BHD response as JSON: {e}") + print(f"Response content: {response.text[:200]}...") + return meta + except (httpx.RequestError, httpx.HTTPStatusError) as e: + print(f"[ERROR] Failed to fetch BHD data: {e}") + return meta + + if data.get("status_code") == 0 or data.get("success") is False: + error_message = data.get("status_message", "Unknown BHD API error") + print(f"[ERROR] BHD API error: {error_message}") + return meta + + # Handle different response formats from BHD API + first_result = None + + # For search results that return a list + if "results" in data and isinstance(data["results"], list) and data["results"]: + first_result = data["results"][0] + + # For single torrent details that return a dictionary in "result" + elif "result" in data and isinstance(data["result"], dict): + first_result = data["result"] + + if not first_result: + print("No valid results found in BHD API response.") + return meta + + name = first_result.get("name", "").lower() + if not torrent_id: + torrent_id = first_result.get("id", 0) + print("BHD Torrent ID:", torrent_id) + + # Check if description is just "1" indicating we need to fetch it separately + description_value = first_result.get("description") + if description_value == 1 or description_value == "1": + + desc_post_data = { + "action": "description", + "torrent_id": torrent_id, + } + + try: + async with httpx.AsyncClient() as client: + desc_response = await client.post(post_query_url, headers=headers, json=desc_post_data, timeout=10) + desc_response.raise_for_status() + desc_data = desc_response.json() + + if desc_data.get("status_code") == 1 and desc_data.get("success") is True: + description = str(desc_data.get("result", "")) + print("Successfully retrieved full description") + else: + description = "" + error_message = desc_data.get("status_message", "Unknown BHD API error") + print(f"[ERROR] Failed to fetch description: {error_message}") + except (httpx.RequestError, httpx.HTTPStatusError) as e: + print(f"[ERROR] Failed to fetch description: {e}") + description = "" + else: + # Use the description from the initial response + description = str(description_value) if description_value is not None else "" + + imdb_id = first_result.get("imdb_id", "").replace("tt", "") if first_result.get("imdb_id") else 0 + meta["imdb_id"] = int(imdb_id or 0) + + raw_tmdb_id = first_result.get("tmdb_id", "") + if raw_tmdb_id and raw_tmdb_id != "0": + meta["category"], parsed_tmdb_id = await parse_tmdb_id(raw_tmdb_id, meta.get("category")) + meta["tmdb_id"] = int(parsed_tmdb_id or 0) + + if only_id and not meta.get('keep_images'): + return meta["imdb_id"] or meta["tmdb_id"] or 0 + + bbcode = BBCODE() + imagelist = [] + if "framestor" in name: + meta["framestor"] = True + elif "flux" in name: + meta["flux"] = True + description, imagelist = bbcode.clean_bhd_description(description, meta) + if not only_id: + meta["description"] = description + meta["image_list"] = imagelist + elif meta.get('keep_images'): + meta["description"] = "" + meta["image_list"] = imagelist + + console.print(f"[green]Found BHD IDs: IMDb={meta.get('imdb_id')}, TMDb={meta.get('tmdb_id')}") + + return meta["imdb_id"] and meta["tmdb_id"] + + +async def parse_tmdb_id(tmdb_id, category): + """Parses TMDb ID, ensures correct formatting, and assigns category.""" + tmdb_id = str(tmdb_id).strip().lower() + + if tmdb_id.startswith('tv/') and '/' in tmdb_id: + tmdb_id = tmdb_id.split('/')[1].split('-')[0] + category = 'TV' + elif tmdb_id.startswith('movie/') and '/' in tmdb_id: + tmdb_id = tmdb_id.split('/')[1].split('-')[0] + category = 'MOVIE' + + return category, tmdb_id diff --git a/src/cleanup.py b/src/cleanup.py new file mode 100644 index 000000000..cd58efbe1 --- /dev/null +++ b/src/cleanup.py @@ -0,0 +1,265 @@ +import asyncio +import psutil +import threading +import multiprocessing +import sys +import os +import subprocess +import re +import platform +from src.console import console +from concurrent.futures import ThreadPoolExecutor +if os.name == "posix": + import termios + +# Detect Android environment +IS_ANDROID = ('android' in platform.platform().lower() or + os.path.exists('/system/build.prop') or + 'ANDROID_ROOT' in os.environ) + +running_subprocesses = set() +thread_executor: ThreadPoolExecutor = None +IS_MACOS = sys.platform == 'darwin' + + +async def cleanup(): + """Ensure all running tasks, threads, and subprocesses are properly cleaned up before exiting.""" + # console.print("[yellow]Cleaning up tasks before exiting...[/yellow]") + + # Step 1: Shutdown ThreadPoolExecutor **before checking for threads** + global thread_executor + if thread_executor: + # console.print("[yellow]Shutting down thread pool executor...[/yellow]") + thread_executor.shutdown(wait=True) # Ensure threads terminate before proceeding + thread_executor = None # Remove reference + + # 🔹 Step 1: Stop the monitoring thread safely + # if not stop_monitoring.is_set(): + # console.print("[yellow]Stopping thread monitor...[/yellow]") + # stop_monitoring.set() # Tell monitoring thread to stop + + # 🔹 Step 2: Wait for the monitoring thread to exit completely + # if monitor_thread and monitor_thread.is_alive(): + # console.print("[yellow]Waiting for monitoring thread to exit...[/yellow]") + # monitor_thread.join(timeout=3) # Ensure complete shutdown + # if monitor_thread.is_alive(): + # console.print("[red]Warning: Monitoring thread did not exit in time.[/red]") + + # 🔹 Step 3: Terminate all tracked subprocesses + while running_subprocesses: + proc = running_subprocesses.pop() + if proc.returncode is None: # If still running + # console.print(f"[yellow]Terminating subprocess {proc.pid}...[/yellow]") + try: + proc.terminate() # Send SIGTERM first + try: + await asyncio.wait_for(proc.wait(), timeout=3) # Wait for process to exit + except asyncio.TimeoutError: + if not IS_ANDROID: # Only try force kill on non-Android + # console.print(f"[red]Subprocess {proc.pid} did not exit in time, force killing.[/red]") + try: + proc.kill() # Force kill if it doesn't exit + except (PermissionError, OSError): + # Can't force kill on Android, just continue + pass + except (PermissionError, OSError): + # Android doesn't allow process termination in many cases + if not IS_ANDROID: + console.print(f"[yellow]Cannot terminate process {proc.pid}: Permission denied[/yellow]") + + # 🔹 Close process streams safely + for stream in (proc.stdout, proc.stderr, proc.stdin): + if stream: + try: + stream.close() + except Exception: + pass + + # 🔹 Step 4: Ensure subprocess transport cleanup + try: + await asyncio.sleep(0.1) + except RuntimeError: + # Event loop is no longer running, skip sleep + pass + + # 🔹 Step 5: Cancel all running asyncio tasks **gracefully** + try: + tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()] + # console.print(f"[yellow]Cancelling {len(tasks)} remaining tasks...[/yellow]") + + for task in tasks: + task.cancel() + + # Stage 1: Give tasks a moment to cancel themselves + try: + await asyncio.sleep(0.1) + except RuntimeError: + # Event loop is no longer running, skip sleep + pass + + # Stage 2: Gather tasks with exception handling + if tasks: # Only gather if there are tasks + try: + results = await asyncio.gather(*tasks, return_exceptions=True) + except RuntimeError: + # Event loop is no longer running, skip gather + results = [] + else: + results = [] + except RuntimeError: + # Event loop is no longer running, skip task cleanup + results = [] + + for result in results: + if isinstance(result, Exception) and not isinstance(result, asyncio.CancelledError): + console.print(f"[red]Error during cleanup: {result}[/red]") + + # 🔹 Step 6: Kill all remaining threads and orphaned processes + kill_all_threads() + + if IS_MACOS: + try: + # Ensure any multiprocessing resources are properly released + multiprocessing.resource_tracker._resource_tracker = None + except Exception: + console.print("[red]Error releasing multiprocessing resources.[/red]") + pass + + # console.print("[green]Cleanup completed. Exiting safely.[/green]") + + +def kill_all_threads(): + """Forcefully kill any lingering threads and subprocesses before exit.""" + # console.print("[yellow]Checking for remaining background threads...[/yellow]") + + # 🔹 Kill any lingering subprocesses + if IS_ANDROID: + # On Android, we have limited process access - just clean up what we can + try: + # Only try to clean up processes we directly spawned + for proc in list(running_subprocesses): + try: + if proc.returncode is None: + proc.terminate() + except (PermissionError, psutil.AccessDenied, OSError): + # Android doesn't allow process termination in many cases + pass + except Exception: + # Silently handle Android permission issues + pass + else: + # Standard process cleanup for non-Android systems + try: + current_process = psutil.Process() + children = current_process.children(recursive=True) + + for child in children: + # console.print(f"[yellow]Terminating process {child.pid}...[/yellow]") + try: + child.terminate() + except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError): + pass + + # Wait for a short time for processes to terminate + if not IS_MACOS: + try: + _, still_alive = psutil.wait_procs(children, timeout=3) + for child in still_alive: + # console.print(f"[red]Force killing stubborn process: {child.pid}[/red]") + try: + child.kill() + except (psutil.NoSuchProcess, psutil.AccessDenied, PermissionError): + pass + except (psutil.AccessDenied, PermissionError): + # Handle systems where we can't wait for processes + pass + except (PermissionError, psutil.AccessDenied, OSError) as e: + if not IS_ANDROID: + console.print(f"[yellow]Limited process access: {e}[/yellow]") + except Exception as e: + console.print(f"[red]Error during process cleanup: {e}[/red]") + + # 🔹 For macOS, specifically check and terminate any multiprocessing processes + if IS_MACOS and hasattr(multiprocessing, 'active_children'): + for child in multiprocessing.active_children(): + try: + child.terminate() + child.join(1) # Wait 1 second for it to terminate + except Exception: + pass + + # 🔹 Remove references to completed threads + try: + for thread in threading.enumerate(): + if thread != threading.current_thread() and not thread.is_alive(): + try: + if hasattr(thread, '_delete'): + thread._delete() + except Exception: + pass + except Exception as e: + console.print(f"[red]Error cleaning up threads: {e}[/red]") + pass + + # 🔹 Print remaining active threads + # active_threads = [t for t in threading.enumerate()] + # console.print(f"[bold yellow]Remaining active threads:[/bold yellow] {len(active_threads)}") + # for t in active_threads: + # console.print(f" - {t.name} (Alive: {t.is_alive()})") + + # console.print("[green]Thread cleanup completed.[/green]") + + +# Wrapped "erase key check and save" in tty check so that Python won't complain if UA is called by a script +if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty() and not sys.stdin.closed: + try: + output = subprocess.check_output(['stty', '-a']).decode() + erase_key = re.search(r' erase = (\S+);', output).group(1) + except (IOError, OSError): + pass + + +def reset_terminal(): + """Reset the terminal while allowing the script to continue running (Linux/macOS only).""" + if os.name != "posix" or IS_ANDROID: + return # Skip terminal reset on Windows and Android + + try: + if not sys.stderr.closed: + sys.stderr.flush() + + if hasattr(sys.stdin, 'isatty') and sys.stdin.isatty() and not sys.stdin.closed: + try: + subprocess.run(["stty", "sane"], check=False) + subprocess.run(["stty", "erase", erase_key], check=False) # explicitly restore backspace character to original value + if hasattr(termios, 'tcflush'): + termios.tcflush(sys.stdin.fileno(), termios.TCIOFLUSH) + subprocess.run(["stty", "-ixon"], check=False) + except (IOError, OSError): + pass + + if not sys.stdout.closed: + try: + sys.stdout.write("\033[0m") + sys.stdout.flush() + sys.stdout.write("\033[?25h") + sys.stdout.flush() + except (IOError, ValueError): + pass + + # Kill background jobs + try: + os.system("jobs -p | xargs -r kill 2>/dev/null") + except Exception: + pass + + if not sys.stderr.closed: + sys.stderr.flush() + + except Exception as e: + try: + if not sys.stderr.closed: + sys.stderr.write(f"Error during terminal reset: {e}\n") + sys.stderr.flush() + except Exception: + pass # At this point we can't do much more diff --git a/src/clients.py b/src/clients.py index c8d5fcba1..e562e62d7 100644 --- a/src/clients.py +++ b/src/clients.py @@ -1,181 +1,424 @@ # -*- coding: utf-8 -*- -from torf import Torrent -import xmlrpc.client +import aiohttp +import asyncio +import base64 import bencode +import errno import os +import platform import qbittorrentapi -from deluge_client import DelugeRPCClient, LocalDelugeRPCClient -import base64 -from pyrobase.parts import Bunch -import errno -import asyncio -import ssl +import re import shutil +import ssl +import subprocess import time - - -from src.console import console - +import traceback +import transmission_rpc +import xmlrpc.client +from cogs.redaction import redact_private_info +from deluge_client import DelugeRPCClient +from src.console import console +from src.torrentcreate import create_base_from_existing_torrent +from torf import Torrent class Clients(): - """ - Add to torrent client - """ def __init__(self, config): self.config = config pass - + + async def retry_qbt_operation(self, operation_func, operation_name, max_retries=2, initial_timeout=10.0): + for attempt in range(max_retries + 1): + timeout = initial_timeout * (2 ** attempt) # Exponential backoff: 10s, 20s, 40s + try: + result = await asyncio.wait_for(operation_func(), timeout=timeout) + if attempt > 0: + console.print(f"[green]{operation_name} succeeded on attempt {attempt + 1}") + return result + except asyncio.TimeoutError: + if attempt < max_retries: + console.print(f"[yellow]{operation_name} timed out after {timeout}s (attempt {attempt + 1}/{max_retries + 1}), retrying...") + await asyncio.sleep(1) # Brief pause before retry + else: + console.print(f"[bold red]{operation_name} failed after {max_retries + 1} attempts (final timeout: {timeout}s)") + raise # Re-raise the TimeoutError so caller can handle it async def add_to_client(self, meta, tracker): - torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}]{meta['clean_name']}.torrent" - if meta.get('no_seed', False) == True: - console.print(f"[bold red]--no-seed was passed, so the torrent will not be added to the client") - console.print(f"[bold yellow]Add torrent manually to the client") + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}].torrent" + if meta.get('no_seed', False) is True: + console.print("[bold red]--no-seed was passed, so the torrent will not be added to the client") + console.print("[bold yellow]Add torrent manually to the client") return if os.path.exists(torrent_path): torrent = Torrent.read(torrent_path) else: return - if meta.get('client', None) == None: + if meta.get('client', None) is None: default_torrent_client = self.config['DEFAULT']['default_torrent_client'] else: default_torrent_client = meta['client'] if meta.get('client', None) == 'none': return if default_torrent_client == "none": - return + return client = self.config['TORRENT_CLIENTS'][default_torrent_client] torrent_client = client['torrent_client'] - + local_path, remote_path = await self.remote_path_map(meta) - - console.print(f"[bold green]Adding to {torrent_client}") + + if meta['debug']: + console.print(f"[bold green]Adding to {torrent_client}") if torrent_client.lower() == "rtorrent": - self.rtorrent(meta['path'], torrent_path, torrent, meta, local_path, remote_path, client) + self.rtorrent(meta['path'], torrent_path, torrent, meta, local_path, remote_path, client, tracker) elif torrent_client == "qbit": - await self.qbittorrent(meta['path'], torrent, local_path, remote_path, client, meta['is_disc'], meta['filelist'], meta) + await self.qbittorrent(meta['path'], torrent, local_path, remote_path, client, meta['is_disc'], meta['filelist'], meta, tracker) elif torrent_client.lower() == "deluge": if meta['type'] == "DISC": - path = os.path.dirname(meta['path']) + path = os.path.dirname(meta['path']) # noqa F841 self.deluge(meta['path'], torrent_path, torrent, local_path, remote_path, client, meta) + elif torrent_client.lower() == "transmission": + self.transmission(meta['path'], torrent, local_path, remote_path, client, meta) elif torrent_client.lower() == "watch": shutil.copy(torrent_path, client['watch_folder']) return - - async def find_existing_torrent(self, meta): - if meta.get('client', None) == None: + if meta.get('client', None) is None: default_torrent_client = self.config['DEFAULT']['default_torrent_client'] else: default_torrent_client = meta['client'] if meta.get('client', None) == 'none' or default_torrent_client == 'none': return None + client = self.config['TORRENT_CLIENTS'][default_torrent_client] - torrent_storage_dir = client.get('torrent_storage_dir', None) - torrent_client = client.get('torrent_client', None).lower() - if torrent_storage_dir == None and torrent_client != "watch": - console.print(f'[bold red]Missing torrent_storage_dir for {default_torrent_client}') - return None - elif not os.path.exists(str(torrent_storage_dir)) and torrent_client != "watch": - console.print(f"[bold red]Invalid torrent_storage_dir path: [bold yellow]{torrent_storage_dir}") - torrenthash = None - if torrent_storage_dir != None and os.path.exists(torrent_storage_dir): - if meta.get('torrenthash', None) != None: - valid, torrent_path = await self.is_valid_torrent(meta, f"{torrent_storage_dir}/{meta['torrenthash']}.torrent", meta['torrenthash'], torrent_client, print_err=True) + client_name = client.get('torrent_client', '').lower() + torrent_storage_dir = client.get('torrent_storage_dir') + torrent_client = client.get('torrent_client', '').lower() + mtv_config = self.config['TRACKERS'].get('MTV') + if isinstance(mtv_config, dict): + prefer_small_pieces = mtv_config.get('prefer_mtv_torrent', False) + else: + prefer_small_pieces = False + best_match = None # Track the best match for fallback if prefer_small_pieces is enabled + + # Iterate through pre-specified hashes + for hash_key in ['torrenthash', 'ext_torrenthash']: + hash_value = meta.get(hash_key) + if hash_value: + # If no torrent_storage_dir defined, use saved torrent from qbit + extracted_torrent_dir = os.path.join(meta.get('base_dir', ''), "tmp", meta.get('uuid', '')) + + if torrent_storage_dir: + torrent_path = os.path.join(torrent_storage_dir, f"{hash_value}.torrent") + else: + if client_name != 'qbit': + return None + + # Fetch from qBittorrent since we don't have torrent_storage_dir + console.print(f"[yellow]Fetching .torrent file from qBittorrent for hash: {hash_value}") + + try: + proxy_url = client.get('qui_proxy_url') + if proxy_url: + qbt_proxy_url = proxy_url.rstrip('/') + async with aiohttp.ClientSession() as session: + try: + async with session.post(f"{qbt_proxy_url}/api/v2/torrents/export", + data={'hash': hash_value}) as response: + if response.status == 200: + torrent_file_content = await response.read() + else: + console.print(f"[red]Failed to export torrent via proxy: {response.status}") + continue + except Exception as e: + console.print(f"[red]Error exporting torrent via proxy: {e}") + continue + else: + qbt_client = qbittorrentapi.Client( + host=client['qbit_url'], + port=client['qbit_port'], + username=client['qbit_user'], + password=client['qbit_pass'], + VERIFY_WEBUI_CERTIFICATE=client.get('VERIFY_WEBUI_CERTIFICATE', True) + ) + try: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.auth_log_in), + "qBittorrent login" + ) + except (asyncio.TimeoutError, qbittorrentapi.LoginFailed, qbittorrentapi.APIConnectionError): + continue + + try: + torrent_file_content = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_export, torrent_hash=hash_value), + f"Export torrent {hash_value}" + ) + except (asyncio.TimeoutError, qbittorrentapi.APIError): + continue + if not torrent_file_content: + console.print(f"[bold red]qBittorrent returned an empty response for hash {hash_value}") + continue # Skip to the next hash + + # Save the .torrent file + os.makedirs(extracted_torrent_dir, exist_ok=True) + torrent_path = os.path.join(extracted_torrent_dir, f"{hash_value}.torrent") + + with open(torrent_path, "wb") as f: + f.write(torrent_file_content) + + console.print(f"[green]Successfully saved .torrent file: {torrent_path}") + + except qbittorrentapi.APIError as e: + console.print(f"[bold red]Failed to fetch .torrent from qBittorrent for hash {hash_value}: {e}") + continue + + # Validate the .torrent file + valid, resolved_path = await self.is_valid_torrent(meta, torrent_path, hash_value, torrent_client, client, print_err=True) + if valid: - torrenthash = meta['torrenthash'] - elif meta.get('ext_torrenthash', None) != None: - valid, torrent_path = await self.is_valid_torrent(meta, f"{torrent_storage_dir}/{meta['ext_torrenthash']}.torrent", meta['ext_torrenthash'], torrent_client, print_err=True) + console.print(f"[green]Found a valid torrent: [bold yellow]{hash_value}") + return resolved_path + + # Search the client if no pre-specified hash matches + if torrent_client == 'qbit' and client.get('enable_search'): + try: + qbt_client, qbt_session, proxy_url = None, None, None + + proxy_url = client.get('qui_proxy_url') + + if proxy_url: + qbt_session = aiohttp.ClientSession() + else: + qbt_client = qbittorrentapi.Client( + host=client['qbit_url'], + port=client['qbit_port'], + username=client['qbit_user'], + password=client['qbit_pass'], + VERIFY_WEBUI_CERTIFICATE=client.get('VERIFY_WEBUI_CERTIFICATE', True) + ) + try: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.auth_log_in), + "qBittorrent login" + ) + except (asyncio.TimeoutError, qbittorrentapi.LoginFailed, qbittorrentapi.APIConnectionError) as e: + console.print(f"[bold red]Failed to connect to qBittorrent: {e}") + qbt_client = None + + found_hash = await self.search_qbit_for_torrent(meta, client, qbt_client, qbt_session, proxy_url) + + # Clean up session if we created one + if qbt_session: + await qbt_session.close() + + except KeyboardInterrupt: + console.print("[bold red]Search cancelled by user") + found_hash = None + if qbt_session: + await qbt_session.close() + except asyncio.TimeoutError: + if qbt_session: + await qbt_session.close() + raise + except Exception as e: + console.print(f"[bold red]Error searching qBittorrent: {e}") + found_hash = None + if qbt_session: + await qbt_session.close() + if found_hash: + extracted_torrent_dir = os.path.join(meta.get('base_dir', ''), "tmp", meta.get('uuid', '')) + found_torrent_path = os.path.join(torrent_storage_dir, f"{found_hash}.torrent") if torrent_storage_dir else os.path.join(extracted_torrent_dir, f"{found_hash}.torrent") + + valid, resolved_path = await self.is_valid_torrent( + meta, found_torrent_path, found_hash, torrent_client, client, print_err=False + ) + if valid: - torrenthash = meta['ext_torrenthash'] - if torrent_client == 'qbit' and torrenthash == None and client.get('enable_search') == True: - torrenthash = await self.search_qbit_for_torrent(meta, client) - if not torrenthash: - console.print("[bold yellow]No Valid .torrent found") - if not torrenthash: - return None - torrent_path = f"{torrent_storage_dir}/{torrenthash}.torrent" - valid2, torrent_path = await self.is_valid_torrent(meta, torrent_path, torrenthash, torrent_client, print_err=False) - if valid2: - return torrent_path - - return None + torrent = Torrent.read(resolved_path) + piece_size = torrent.piece_size + piece_in_mib = int(piece_size) / 1024 / 1024 + + if not prefer_small_pieces: + console.print(f"[green]Found a valid torrent from client search with piece size {piece_in_mib} MiB: [bold yellow]{found_hash}") + return resolved_path + + # Track best match for small pieces + if piece_size <= 8388608: + console.print(f"[green]Found a valid torrent with preferred piece size from client search: [bold yellow]{found_hash}") + return resolved_path + if best_match is None or piece_size < best_match['piece_size']: + best_match = {'torrenthash': found_hash, 'torrent_path': resolved_path, 'piece_size': piece_size} + console.print(f"[yellow]Storing valid torrent from client search as best match: [bold yellow]{found_hash}") + + # Use best match if no preferred torrent found + if prefer_small_pieces and best_match: + console.print(f"[yellow]Using best match torrent with hash: [bold yellow]{best_match['torrenthash']}[/bold yellow]") + return best_match['torrent_path'] + + console.print("[bold yellow]No Valid .torrent found") + return None - async def is_valid_torrent(self, meta, torrent_path, torrenthash, torrent_client, print_err=False): + async def is_valid_torrent(self, meta, torrent_path, torrenthash, torrent_client, client, print_err=False): valid = False wrong_file = False - err_print = "" + + # Normalize the torrent hash based on the client if torrent_client in ('qbit', 'deluge'): torrenthash = torrenthash.lower().strip() torrent_path = torrent_path.replace(torrenthash.upper(), torrenthash) elif torrent_client == 'rtorrent': torrenthash = torrenthash.upper().strip() torrent_path = torrent_path.replace(torrenthash.upper(), torrenthash) + if meta['debug']: - console.log(torrent_path) + console.log(f"Torrent path after normalization: {torrent_path}") + + # Check if torrent file exists if os.path.exists(torrent_path): - torrent = Torrent.read(torrent_path) - # Reuse if disc and basename matches - if meta.get('is_disc', None) != None: + try: + torrent = Torrent.read(torrent_path) + except Exception as e: + console.print(f'[bold red]Error reading torrent file: {e}') + return valid, torrent_path + + # Reuse if disc and basename matches or --keep-folder was specified + if meta.get('is_disc', None) is not None or (meta['keep_folder'] and meta['isdir']): + torrent_name = torrent.metainfo['info']['name'] + if meta['uuid'] != torrent_name and meta['debug']: + console.print("Modified file structure, skipping hash") + valid = False torrent_filepath = os.path.commonpath(torrent.files) if os.path.basename(meta['path']) in torrent_filepath: valid = True + if meta['debug']: + console.log(f"Torrent is valid based on disc/basename or keep-folder: {valid}") + # If one file, check for folder - if len(torrent.files) == len(meta['filelist']) == 1: + elif len(torrent.files) == len(meta['filelist']) == 1: if os.path.basename(torrent.files[0]) == os.path.basename(meta['filelist'][0]): if str(torrent.files[0]) == os.path.basename(torrent.files[0]): valid = True - else: - wrong_file = True + else: + wrong_file = True + if meta['debug']: + console.log(f"Single file match status: valid={valid}, wrong_file={wrong_file}") + # Check if number of files matches number of videos elif len(torrent.files) == len(meta['filelist']): torrent_filepath = os.path.commonpath(torrent.files) actual_filepath = os.path.commonpath(meta['filelist']) local_path, remote_path = await self.remote_path_map(meta) + if local_path.lower() in meta['path'].lower() and local_path.lower() != remote_path.lower(): - actual_filepath = torrent_path.replace(local_path, remote_path) - actual_filepath = torrent_path.replace(os.sep, '/') + actual_filepath = actual_filepath.replace(local_path, remote_path).replace(os.sep, '/') + if meta['debug']: - console.log(f"torrent_filepath: {torrent_filepath}") - console.log(f"actual_filepath: {actual_filepath}") + console.log(f"Torrent_filepath: {torrent_filepath}") + console.log(f"Actual_filepath: {actual_filepath}") + if torrent_filepath in actual_filepath: valid = True + if meta['debug']: + console.log(f"Multiple file match status: valid={valid}") + else: console.print(f'[bold yellow]{torrent_path} was not found') + + # Additional checks if the torrent is valid so far if valid: if os.path.exists(torrent_path): - reuse_torrent = Torrent.read(torrent_path) - if (reuse_torrent.pieces >= 7000 and reuse_torrent.piece_size < 8388608) or (reuse_torrent.pieces >= 4000 and reuse_torrent.piece_size < 4194304): # Allow up to 7k pieces at 8MiB or 4k pieces at 4MiB or less - err_print = "[bold yellow]Too many pieces exist in current hash. REHASHING" - valid = False - elif reuse_torrent.piece_size < 32768: - err_print = "[bold yellow]Piece size too small to reuse" - valid = False - elif wrong_file == True: - err_print = "[bold red] Provided .torrent has files that were not expected" + try: + reuse_torrent = Torrent.read(torrent_path) + piece_size = reuse_torrent.piece_size + piece_in_mib = int(piece_size) / 1024 / 1024 + torrent_storage_dir_valid = torrent_path + torrent_file_size_kib = round(os.path.getsize(torrent_storage_dir_valid) / 1024, 2) + if meta['debug']: + console.log(f"Checking piece size, count and size: pieces={reuse_torrent.pieces}, piece_size={piece_in_mib} MiB, .torrent size={torrent_file_size_kib} KiB") + + # Piece size and count validations + if not meta.get('prefer_small_pieces', False): + if reuse_torrent.pieces >= 8000 and reuse_torrent.piece_size < 8488608: + if meta['debug']: + console.print("[bold red]Torrent needs to have less than 8000 pieces with a 8 MiB piece size") + valid = False + elif reuse_torrent.pieces >= 4000 and reuse_torrent.piece_size < 4294304: + if meta['debug']: + console.print("[bold red]Torrent needs to have less than 5000 pieces with a 4 MiB piece size") + valid = False + elif 'max_piece_size' not in meta and reuse_torrent.pieces >= 12000: + if meta['debug']: + console.print("[bold red]Torrent needs to have less than 12000 pieces to be valid") + valid = False + elif reuse_torrent.piece_size < 32768: + if meta['debug']: + console.print("[bold red]Piece size too small to reuse") + valid = False + elif 'max_piece_size' not in meta and torrent_file_size_kib > 250: + if meta['debug']: + console.log("[bold red]Torrent file size exceeds 250 KiB") + valid = False + elif wrong_file: + if meta['debug']: + console.log("[bold red]Provided .torrent has files that were not expected") + valid = False + else: + console.print(f"[bold green]REUSING .torrent with infohash: [bold yellow]{torrenthash}") + except Exception as e: + console.print(f'[bold red]Error checking reuse torrent: {e}') valid = False - else: - err_print = f'[bold green]REUSING .torrent with infohash: [bold yellow]{torrenthash}' + + if meta['debug']: + console.log(f"Final validity after piece checks: valid={valid}") else: - err_print = '[bold yellow]Unwanted Files/Folders Identified' - if print_err: - console.print(err_print) + if meta['debug']: + console.log("[bold yellow]Unwanted Files/Folders Identified") + return valid, torrent_path + async def search_qbit_for_torrent(self, meta, client, qbt_client=None, qbt_session=None, proxy_url=None): + mtv_config = self.config['TRACKERS'].get('MTV') + if isinstance(mtv_config, dict): + prefer_small_pieces = mtv_config.get('prefer_mtv_torrent', False) + else: + prefer_small_pieces = False + console.print("[green]Searching qBittorrent for an existing .torrent") + + torrent_storage_dir = client.get('torrent_storage_dir') + extracted_torrent_dir = os.path.join(meta.get('base_dir', ''), "tmp", meta.get('uuid', '')) - async def search_qbit_for_torrent(self, meta, client): - console.print("[green]Searching qbittorrent for an existing .torrent") - torrent_storage_dir = client.get('torrent_storage_dir', None) - if torrent_storage_dir == None and client.get("torrent_client", None) != "watch": - console.print(f"[bold red]Missing torrent_storage_dir for {self.config['DEFAULT']['default_torrent_client']}") + if not extracted_torrent_dir or extracted_torrent_dir.strip() == "tmp/": + console.print("[bold red]Invalid extracted torrent directory path. Check `meta['base_dir']` and `meta['uuid']`.") return None try: - qbt_client = qbittorrentapi.Client(host=client['qbit_url'], port=client['qbit_port'], username=client['qbit_user'], password=client['qbit_pass'], VERIFY_WEBUI_CERTIFICATE=client.get('VERIFY_WEBUI_CERTIFICATE', True)) - qbt_client.auth_log_in() + if qbt_client is None and proxy_url is None: + qbt_client = qbittorrentapi.Client( + host=client['qbit_url'], + port=client['qbit_port'], + username=client['qbit_user'], + password=client['qbit_pass'], + VERIFY_WEBUI_CERTIFICATE=client.get('VERIFY_WEBUI_CERTIFICATE', True) + ) + try: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.auth_log_in), + "qBittorrent login" + ) + except qbittorrentapi.LoginFailed: + console.print("[bold red]INCORRECT QBIT LOGIN CREDENTIALS") + return None + except qbittorrentapi.APIConnectionError: + console.print("[bold red]APIConnectionError: INCORRECT HOST/PORT") + return None + except asyncio.TimeoutError: + console.print("[bold red]Login to qBittorrent timed out after retries") + return None + elif proxy_url and qbt_session is None: + qbt_session = aiohttp.ClientSession() + except qbittorrentapi.LoginFailed: console.print("[bold red]INCORRECT QBIT LOGIN CREDENTIALS") return None @@ -183,70 +426,404 @@ async def search_qbit_for_torrent(self, meta, client): console.print("[bold red]APIConnectionError: INCORRECT HOST/PORT") return None - # Remote path map if needed - remote_path_map = False - local_path, remote_path = await self.remote_path_map(meta) - if local_path.lower() in meta['path'].lower() and local_path.lower() != remote_path.lower(): - remote_path_map = True + # Ensure extracted torrent directory exists + os.makedirs(extracted_torrent_dir, exist_ok=True) + + # **Step 1: Find correct torrents using content_path** + best_match = None + matching_torrents = [] + + try: + if proxy_url: + qbt_proxy_url = proxy_url.rstrip('/') + async with qbt_session.get(f"{qbt_proxy_url}/api/v2/torrents/info") as response: + if response.status == 200: + torrents_data = await response.json() + + class MockTorrent: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + if not hasattr(self, 'files'): + self.files = [] + if not hasattr(self, 'tracker'): + self.tracker = '' + if not hasattr(self, 'comment'): + self.comment = '' + torrents = [MockTorrent(torrent) for torrent in torrents_data] + else: + console.print(f"[bold red]Failed to get torrents list via proxy: {response.status}") + return None + else: + torrents = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_info), + "Get torrents list", + initial_timeout=14.0 + ) + except asyncio.TimeoutError: + console.print("[bold red]Getting torrents list timed out after retries") + return None + except Exception as e: + console.print(f"[bold red]Error getting torrents list: {e}") + return None - torrents = qbt_client.torrents.info() for torrent in torrents: try: - torrent_path = torrent.get('content_path', f"{torrent.save_path}{torrent.name}") + torrent_path = torrent.name except AttributeError: - if meta['debug']: - console.print(torrent) - console.print_exception() - continue - if remote_path_map: - torrent_path = torrent_path.replace(remote_path, local_path) - torrent_path = torrent_path.replace(os.sep, '/').replace('/', os.sep) + continue # Ignore torrents with missing attributes if meta['is_disc'] in ("", None) and len(meta['filelist']) == 1: - if torrent_path == meta['filelist'][0] and len(torrent.files) == len(meta['filelist']): - valid, torrent_path = await self.is_valid_torrent(meta, f"{torrent_storage_dir}/{torrent.hash}.torrent", torrent.hash, 'qbit', print_err=False) - if valid: - console.print(f"[green]Found a matching .torrent with hash: [bold yellow]{torrent.hash}") - return torrent.hash - elif meta['path'] == torrent_path: - valid, torrent_path = await self.is_valid_torrent(meta, f"{torrent_storage_dir}/{torrent.hash}.torrent", torrent.hash, 'qbit', print_err=False) - if valid: - console.print(f"[green]Found a matching .torrent with hash: [bold yellow]{torrent.hash}") - return torrent.hash - return None + if torrent_path != meta['uuid'] or len(torrent.files) != len(meta['filelist']): + continue + + elif meta['uuid'] != torrent_path: + continue + + if meta['debug']: + console.print(f"[cyan]Matched Torrent: {torrent.hash}") + console.print(f"Name: {torrent.name}") + console.print(f"Save Path: {torrent.save_path}") + console.print(f"Content Path: {torrent_path}") + + matching_torrents.append({'hash': torrent.hash, 'name': torrent.name}) + + if not matching_torrents: + console.print("[yellow]No matching torrents found in qBittorrent.") + return None + + console.print(f"[green]Total Matching Torrents: {len(matching_torrents)}") + + # **Step 2: Extract and Save .torrent Files** + processed_hashes = set() + best_match = None + + for torrent in matching_torrents: + try: + torrent_hash = torrent['hash'] + if torrent_hash in processed_hashes: + continue # Avoid processing duplicates + + processed_hashes.add(torrent_hash) + except Exception as e: + console.print(f"[bold red]Unexpected error while handling {torrent_hash}: {e}") + # **Use `torrent_storage_dir` if available** + if torrent_storage_dir: + torrent_file_path = os.path.join(torrent_storage_dir, f"{torrent_hash}.torrent") + if not os.path.exists(torrent_file_path): + console.print(f"[yellow]Torrent file not found in storage directory: {torrent_file_path}") + continue + else: + # **Fetch from qBittorrent API if no `torrent_storage_dir`** + if meta['debug']: + console.print(f"[cyan]Exporting .torrent file for {torrent_hash}") + + torrent_file_content = None + if proxy_url: + qbt_proxy_url = proxy_url.rstrip('/') + try: + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/export", + data={'hash': torrent_hash}) as response: + if response.status == 200: + torrent_file_content = await response.read() + else: + console.print(f"[red]Failed to export torrent via proxy: {response.status}") + except Exception as e: + console.print(f"[red]Error exporting torrent via proxy: {e}") + else: + torrent_file_content = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_export, torrent_hash=torrent_hash), + f"Export torrent {torrent_hash}" + ) + + if torrent_file_content is not None: + torrent_file_path = os.path.join(extracted_torrent_dir, f"{torrent_hash}.torrent") + + with open(torrent_file_path, "wb") as f: + f.write(torrent_file_content) + if meta['debug']: + console.print(f"[green]Successfully saved .torrent file: {torrent_file_path}") + else: + console.print(f"[bold red]Failed to export .torrent for {torrent_hash} after retries") + continue # Skip this torrent if unable to fetch + + # **Validate the .torrent file** + try: + valid, torrent_path = await self.is_valid_torrent(meta, torrent_file_path, torrent_hash, 'qbit', client, print_err=False) + except Exception as e: + console.print(f"[bold red]Error validating torrent {torrent_hash}: {e}") + valid = False + torrent_path = None + if valid: + if prefer_small_pieces: + # **Track best match based on piece size** + try: + torrent_data = Torrent.read(torrent_file_path) + piece_size = torrent_data.piece_size + if best_match is None or piece_size < best_match['piece_size']: + best_match = { + 'hash': torrent_hash, + 'torrent_path': torrent_path if torrent_path else torrent_file_path, + 'piece_size': piece_size + } + console.print(f"[green]Updated best match: {best_match}") + except Exception as e: + console.print(f"[bold red]Error reading torrent data for {torrent_hash}: {e}") + continue + else: + # If `prefer_small_pieces` is False, return first valid torrent + console.print(f"[green]Returning first valid torrent: {torrent_hash}") + return torrent_hash + else: + if meta['debug']: + console.print(f"[bold red]{torrent_hash} failed validation") + os.remove(torrent_file_path) + # **Return the best match if `prefer_small_pieces` is enabled** + if best_match: + console.print(f"[green]Using best match torrent with hash: {best_match['hash']}") + result = best_match['hash'] + else: + console.print("[yellow]No valid torrents found.") + result = None + if qbt_session and proxy_url: + await qbt_session.close() + + return result + + def rtorrent(self, path, torrent_path, torrent, meta, local_path, remote_path, client, tracker): + # Get the appropriate source path (same as in qbittorrent method) + if len(meta.get('filelist', [])) == 1 and os.path.isfile(meta['filelist'][0]) and not meta.get('keep_folder'): + # If there's a single file and not keep_folder, use the file itself as the source + src = meta['filelist'][0] + else: + # Otherwise, use the directory + src = meta.get('path') + if not src: + error_msg = "[red]No source path found in meta." + console.print(f"[bold red]{error_msg}") + raise ValueError(error_msg) + + # Determine linking method + linking_method = client.get('linking', None) # "symlink", "hardlink", or None + if meta.get('debug', False): + console.print("Linking method:", linking_method) + use_symlink = linking_method == "symlink" + use_hardlink = linking_method == "hardlink" + + if use_symlink and use_hardlink: + error_msg = "Cannot use both hard links and symlinks simultaneously" + console.print(f"[bold red]{error_msg}") + raise ValueError(error_msg) + + # Process linking if enabled + if use_symlink or use_hardlink: + # Get linked folder for this drive + linked_folder = client.get('linked_folder', []) + if meta.get('debug', False): + console.print(f"Linked folders: {linked_folder}") + if not isinstance(linked_folder, list): + linked_folder = [linked_folder] # Convert to list if single value + + # Determine drive letter (Windows) or root (Linux) + if platform.system() == "Windows": + src_drive = os.path.splitdrive(src)[0] + else: + # On Unix/Linux, use the root directory or first directory component + src_drive = "/" + # Extract the first directory component for more specific matching + src_parts = src.strip('/').split('/') + if src_parts: + src_root_dir = '/' + src_parts[0] + # Check if any linked folder contains this root + for folder in linked_folder: + if src_root_dir in folder or folder in src_root_dir: + src_drive = src_root_dir + break + + # Find a linked folder that matches the drive + link_target = None + if platform.system() == "Windows": + # Windows matching based on drive letters + for folder in linked_folder: + folder_drive = os.path.splitdrive(folder)[0] + if folder_drive == src_drive: + link_target = folder + break + else: + # Unix/Linux matching based on path containment + for folder in linked_folder: + # Check if source path is in the linked folder or vice versa + if src.startswith(folder) or folder.startswith(src) or folder.startswith(src_drive): + link_target = folder + break + + if meta.get('debug', False): + console.print(f"Source drive: {src_drive}") + console.print(f"Link target: {link_target}") + + # If using symlinks and no matching drive folder, allow any available one + if use_symlink and not link_target and linked_folder: + link_target = linked_folder[0] + + if (use_symlink or use_hardlink) and not link_target: + error_msg = f"No suitable linked folder found for drive {src_drive}" + console.print(f"[bold red]{error_msg}") + raise ValueError(error_msg) + + # Create tracker-specific directory inside linked folder + if use_symlink or use_hardlink: + # allow overridden folder name with link_dir_name config var + tracker_cfg = self.config["TRACKERS"].get(tracker.upper(), {}) + link_dir_name = str(tracker_cfg.get("link_dir_name", "")).strip() + tracker_dir = os.path.join(link_target, link_dir_name or tracker) + os.makedirs(tracker_dir, exist_ok=True) + + if meta.get('debug', False): + console.print(f"[bold yellow]Linking to tracker directory: {tracker_dir}") + console.print(f"[cyan]Source path: {src}") + + # Extract only the folder or file name from `src` + src_name = os.path.basename(src.rstrip(os.sep)) # Ensure we get just the name + dst = os.path.join(tracker_dir, src_name) # Destination inside linked folder + + # path magic + if os.path.exists(dst) or os.path.islink(dst): + if meta.get('debug', False): + console.print(f"[yellow]Skipping linking, path already exists: {dst}") + else: + if use_hardlink: + try: + # Check if we're linking a file or directory + if os.path.isfile(src): + # For a single file, create a hardlink directly + try: + os.link(src, dst) + if meta.get('debug', False): + console.print(f"[green]Hard link created: {dst} -> {src}") + except OSError as e: + # If hardlink fails, try to copy the file instead + console.print(f"[yellow]Hard link failed: {e}") + console.print(f"[yellow]Falling back to file copy for: {src}") + shutil.copy2(src, dst) # copy2 preserves metadata + console.print(f"[green]File copied instead: {dst}") + else: + # For directories, we need to link each file inside + os.makedirs(dst, exist_ok=True) + for root, _, files in os.walk(src): + # Get the relative path from source + rel_path = os.path.relpath(root, src) + # Create corresponding directory in destination + if rel_path != '.': + dst_dir = os.path.join(dst, rel_path) + os.makedirs(dst_dir, exist_ok=True) + # Create hardlinks for each file + for file in files: + src_file = os.path.join(root, file) + dst_file = os.path.join(dst if rel_path == '.' else dst_dir, file) + try: + os.link(src_file, dst_file) + if meta.get('debug', False) and files.index(file) == 0: + console.print(f"[green]Hard link created for file: {dst_file} -> {src_file}") + except OSError as e: + # If hardlink fails, copy file instead + console.print(f"[yellow]Hard link failed for file {file}: {e}") + shutil.copy2(src_file, dst_file) # copy2 preserves metadata + console.print(f"[yellow]File copied instead: {dst_file}") + if meta.get('debug', False): + console.print(f"[green]Directory structure and files processed: {dst}") + except OSError as e: + error_msg = f"Failed to create link: {e}" + console.print(f"[bold red]{error_msg}") + if meta.get('debug', False): + console.print(f"[yellow]Source: {src} (exists: {os.path.exists(src)})") + console.print(f"[yellow]Destination: {dst}") + # Don't raise exception - just warn and continue + console.print("[yellow]Continuing with rTorrent addition despite linking failure") + elif use_symlink: + try: + if platform.system() == "Windows": + os.symlink(src, dst, target_is_directory=os.path.isdir(src)) + else: + os.symlink(src, dst) + + if meta.get('debug', False): + console.print(f"[green]Symbolic link created: {dst} -> {src}") + + except OSError as e: + error_msg = f"Failed to create symlink: {e}" + console.print(f"[bold red]{error_msg}") + # Don't raise exception - just warn and continue + console.print("[yellow]Continuing with rTorrent addition despite linking failure") + + # Use the linked path for rTorrent if linking was successful + if (use_symlink or use_hardlink) and os.path.exists(dst): + path = dst + + # Apply remote pathing to `tracker_dir` before assigning `save_path` + if use_symlink or use_hardlink: + save_path = tracker_dir # Default to linked directory + else: + save_path = path # Default to the original path + + # Handle remote path mapping + if local_path and remote_path and local_path.lower() != remote_path.lower(): + # Normalize paths for comparison + norm_save_path = os.path.normpath(save_path).lower() + norm_local_path = os.path.normpath(local_path).lower() + + # Check if the save_path starts with local_path + if norm_save_path.startswith(norm_local_path): + # Get the relative part of the path + rel_path = os.path.relpath(save_path, local_path) + # Combine remote path with relative path + save_path = os.path.join(remote_path, rel_path) + + # For direct replacement if the above approach doesn't work + elif local_path.lower() in save_path.lower(): + save_path = save_path.replace(local_path, remote_path, 1) # Replace only at the beginning + + if meta['debug']: + console.print(f"[cyan]Original path: {path}") + console.print(f"[cyan]Mapped save path: {save_path}") - def rtorrent(self, path, torrent_path, torrent, meta, local_path, remote_path, client): rtorrent = xmlrpc.client.Server(client['rtorrent_url'], context=ssl._create_stdlib_context()) metainfo = bencode.bread(torrent_path) + if meta['debug']: + print(f"{rtorrent}: {redact_private_info(rtorrent)}") + print(f"{metainfo}: {redact_private_info(metainfo)}") try: - fast_resume = self.add_fast_resume(metainfo, path, torrent) + # Use dst path if linking was successful, otherwise use original path + resume_path = dst if (use_symlink or use_hardlink) and os.path.exists(dst) else path + if meta['debug']: + console.print(f"[cyan]Using resume path: {resume_path}") + fast_resume = self.add_fast_resume(metainfo, resume_path, torrent) except EnvironmentError as exc: console.print("[red]Error making fast-resume data (%s)" % (exc,)) raise - - + new_meta = bencode.bencode(fast_resume) if new_meta != metainfo: fr_file = torrent_path.replace('.torrent', '-resume.torrent') - console.print("Creating fast resume") + if meta['debug']: + console.print("Creating fast resume file:", fr_file) bencode.bwrite(fast_resume, fr_file) + # Use dst path if linking was successful, otherwise use original path + path = dst if (use_symlink or use_hardlink) and os.path.exists(dst) else path isdir = os.path.isdir(path) - # if meta['type'] == "DISC": - # path = os.path.dirname(path) - #Remote path mount + # Remote path mount modified_fr = False if local_path.lower() in path.lower() and local_path.lower() != remote_path.lower(): path_dir = os.path.dirname(path) @@ -255,98 +832,517 @@ def rtorrent(self, path, torrent_path, torrent, meta, local_path, remote_path, c shutil.copy(fr_file, f"{path_dir}/fr.torrent") fr_file = f"{os.path.dirname(path)}/fr.torrent" modified_fr = True - if isdir == False: + if meta['debug']: + console.print(f"[cyan]Modified fast resume file path because path mapping: {fr_file}") + if isdir is False: path = os.path.dirname(path) - - + if meta['debug']: + console.print(f"[cyan]Final path for rTorrent: {path}") + console.print("[bold yellow]Adding and starting torrent") rtorrent.load.start_verbose('', fr_file, f"d.directory_base.set={path}") + if meta['debug']: + console.print(f"[green]rTorrent load start for {fr_file} with d.directory_base.set={path}") time.sleep(1) # Add labels - if client.get('rtorrent_label', None) != None: + if client.get('rtorrent_label', None) is not None: + if meta['debug']: + console.print(f"[cyan]Setting rTorrent label: {client['rtorrent_label']}") rtorrent.d.custom1.set(torrent.infohash, client['rtorrent_label']) - if meta.get('rtorrent_label') != None: + if meta.get('rtorrent_label') is not None: rtorrent.d.custom1.set(torrent.infohash, meta['rtorrent_label']) + if meta['debug']: + console.print(f"[cyan]Setting rTorrent label from meta: {meta['rtorrent_label']}") # Delete modified fr_file location if modified_fr: + if meta['debug']: + console.print(f"[cyan]Removing modified fast resume file: {fr_file}") os.remove(f"{path_dir}/fr.torrent") - if meta['debug']: + if meta.get('debug', False): console.print(f"[cyan]Path: {path}") return - - async def qbittorrent(self, path, torrent, local_path, remote_path, client, is_disc, filelist, meta): - # infohash = torrent.infohash - #Remote path mount - isdir = os.path.isdir(path) - if not isdir and len(filelist) == 1: - path = os.path.dirname(path) - if len(filelist) != 1: + async def qbittorrent(self, path, torrent, local_path, remote_path, client, is_disc, filelist, meta, tracker): + if meta.get('keep_folder'): path = os.path.dirname(path) - if local_path.lower() in path.lower() and local_path.lower() != remote_path.lower(): - path = path.replace(local_path, remote_path) - path = path.replace(os.sep, '/') - if not path.endswith(os.sep): - path = f"{path}/" - qbt_client = qbittorrentapi.Client(host=client['qbit_url'], port=client['qbit_port'], username=client['qbit_user'], password=client['qbit_pass'], VERIFY_WEBUI_CERTIFICATE=client.get('VERIFY_WEBUI_CERTIFICATE', True)) - console.print("[bold yellow]Adding and rechecking torrent") - try: - qbt_client.auth_log_in() - except qbittorrentapi.LoginFailed: - console.print("[bold red]INCORRECT QBIT LOGIN CREDENTIALS") - return + else: + isdir = os.path.isdir(path) + if len(filelist) != 1 or not isdir: + path = os.path.dirname(path) + + # Get the appropriate source path + if len(meta['filelist']) == 1 and os.path.isfile(meta['filelist'][0]) and not meta.get('keep_folder'): + # If there's a single file and not keep_folder, use the file itself as the source + src = meta['filelist'][0] + else: + # Otherwise, use the directory + src = meta.get('path') + + if not src: + error_msg = "[red]No source path found in meta." + console.print(f"[bold red]{error_msg}") + raise ValueError(error_msg) + + # Determine linking method + linking_method = client.get('linking', None) # "symlink", "hardlink", or None + if meta['debug']: + console.print("Linking method:", linking_method) + use_symlink = linking_method == "symlink" + use_hardlink = linking_method == "hardlink" + + if use_symlink and use_hardlink: + error_msg = "Cannot use both hard links and symlinks simultaneously" + console.print(f"[bold red]{error_msg}") + raise ValueError(error_msg) + + # Get linked folder for this drive + linked_folder = client.get('linked_folder', []) + if meta['debug']: + console.print(f"Linked folders: {linked_folder}") + if not isinstance(linked_folder, list): + linked_folder = [linked_folder] # Convert to list if single value + + # Determine drive letter (Windows) or root (Linux) + if platform.system() == "Windows": + src_drive = os.path.splitdrive(src)[0] + else: + # On Unix/Linux, use the full mount point path for more accurate matching + src_drive = "/" + + # Get all mount points on the system to find the most specific match + mounted_volumes = [] + try: + # Read mount points from /proc/mounts or use 'mount' command output + if os.path.exists('/proc/mounts'): + with open('/proc/mounts', 'r') as f: + for line in f: + parts = line.split() + if len(parts) >= 2: + mount_point = parts[1] + mounted_volumes.append(mount_point) + else: + # Fall back to mount command if /proc/mounts doesn't exist + output = subprocess.check_output(['mount'], text=True) + for line in output.splitlines(): + parts = line.split() + if len(parts) >= 3: + mount_point = parts[2] + mounted_volumes.append(mount_point) + except Exception as e: + if meta.get('debug', False): + console.print(f"[yellow]Error getting mount points: {str(e)}") + + # Sort mount points by length (descending) to find most specific match first + mounted_volumes.sort(key=len, reverse=True) + + # Find the most specific mount point that contains our source path + for mount_point in mounted_volumes: + if src.startswith(mount_point): + src_drive = mount_point + if meta.get('debug', False): + console.print(f"[cyan]Found mount point: {mount_point} for path: {src}") + break + + # If we couldn't find a specific mount point, fall back to linked folder matching + if src_drive == "/": + # Extract the first directory component for basic matching + src_parts = src.strip('/').split('/') + if src_parts: + src_root_dir = '/' + src_parts[0] + # Check if any linked folder contains this root + for folder in linked_folder: + if src_root_dir in folder or folder in src_root_dir: + src_drive = src_root_dir + break + + # Find a linked folder that matches the drive + link_target = None + if platform.system() == "Windows": + # Windows matching based on drive letters + for folder in linked_folder: + folder_drive = os.path.splitdrive(folder)[0] + if folder_drive == src_drive: + link_target = folder + break + else: + # Unix/Linux matching based on path containment + for folder in linked_folder: + # Check if the linked folder starts with the mount point + if folder.startswith(src_drive) or src.startswith(folder): + link_target = folder + break + + # Also check if this is a sibling mount point with the same structure + folder_parts = folder.split('/') + src_drive_parts = src_drive.split('/') + + # Check if both are mounted under the same parent directory + if (len(folder_parts) >= 2 and len(src_drive_parts) >= 2 and + folder_parts[1] == src_drive_parts[1]): + + potential_match = os.path.join(src_drive, folder_parts[-1]) + if os.path.exists(potential_match): + link_target = potential_match + if meta['debug']: + console.print(f"[cyan]Found sibling mount point linked folder: {link_target}") + break + + if meta['debug']: + console.print(f"Source drive: {src_drive}") + console.print(f"Link target: {link_target}") + # If using symlinks and no matching drive folder, allow any available one + if use_symlink and not link_target and linked_folder: + link_target = linked_folder[0] + + if (use_symlink or use_hardlink) and not link_target: + error_msg = f"No suitable linked folder found for drive {src_drive}" + console.print(f"[bold red]{error_msg}") + raise ValueError(error_msg) + + # Create tracker-specific directory inside linked folder + if use_symlink or use_hardlink: + tracker_dir = os.path.join(link_target, tracker) + await asyncio.to_thread(os.makedirs, tracker_dir, exist_ok=True) + + src_name = os.path.basename(src.rstrip(os.sep)) + dst = os.path.join(tracker_dir, src_name) + + linking_success = await async_link_directory( + src=src, + dst=dst, + use_hardlink=use_hardlink, + debug=meta.get('debug', False) + ) + allow_fallback = self.config['TRACKERS'].get('allow_fallback', True) + if not linking_success and allow_fallback: + console.print(f"[yellow]Using original path without linking: {src}") + # Reset linking settings for fallback + use_hardlink = False + use_symlink = False + + proxy_url = client.get('qui_proxy_url') + qbt_client = None + qbt_session = None + + if proxy_url: + qbt_session = aiohttp.ClientSession() + qbt_proxy_url = proxy_url.rstrip('/') + else: + qbt_client = qbittorrentapi.Client( + host=client['qbit_url'], + port=client['qbit_port'], + username=client['qbit_user'], + password=client['qbit_pass'], + VERIFY_WEBUI_CERTIFICATE=client.get('VERIFY_WEBUI_CERTIFICATE', True) + ) + + if meta['debug']: + console.print("[bold yellow]Adding and rechecking torrent") + + if qbt_client: + try: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.auth_log_in), + "qBittorrent login" + ) + except (asyncio.TimeoutError, qbittorrentapi.LoginFailed): + console.print("[bold red]Failed to login to qBittorrent") + return + except qbittorrentapi.APIConnectionError: + console.print("[bold red]Failed to connect to qBittorrent") + return + + # Apply remote pathing to `tracker_dir` before assigning `save_path` + if use_symlink or use_hardlink: + save_path = tracker_dir # Default to linked directory + else: + save_path = path # Default to the original path + + # Handle remote path mapping + if local_path and remote_path and local_path.lower() != remote_path.lower(): + # Normalize paths for comparison + norm_save_path = os.path.normpath(save_path).lower() + norm_local_path = os.path.normpath(local_path).lower() + + # Check if the save_path starts with local_path + if norm_save_path.startswith(norm_local_path): + # Get the relative part of the path + rel_path = os.path.relpath(save_path, local_path) + # Combine remote path with relative path + save_path = os.path.join(remote_path, rel_path) + + # For direct replacement if the above approach doesn't work + elif local_path.lower() in save_path.lower(): + save_path = save_path.replace(local_path, remote_path, 1) # Replace only at the beginning + + # Always normalize separators for qBittorrent (it expects forward slashes) + save_path = save_path.replace(os.sep, '/') + + # Ensure qBittorrent save path is formatted correctly + if not save_path.endswith('/'): + save_path += '/' + + if meta['debug']: + console.print(f"[cyan]Original path: {path}") + console.print(f"[cyan]Mapped save path: {save_path}") + + # Automatic management auto_management = False - am_config = client.get('automatic_management_paths', '') - if isinstance(am_config, list): - for each in am_config: - if os.path.normpath(each).lower() in os.path.normpath(path).lower(): + if not use_symlink and not use_hardlink: + am_config = client.get('automatic_management_paths', '') + if meta['debug']: + console.print(f"AM Config: {am_config}") + if isinstance(am_config, list): + for each in am_config: + if os.path.normpath(each).lower() in os.path.normpath(path).lower(): + auto_management = True + else: + if os.path.normpath(am_config).lower() in os.path.normpath(path).lower() and am_config.strip() != "": auto_management = True - else: - if os.path.normpath(am_config).lower() in os.path.normpath(path).lower() and am_config.strip() != "": - auto_management = True - qbt_category = client.get("qbit_cat") if not meta.get("qbit_cat") else meta.get('qbit_cat') + qbt_category = client.get("qbit_cat") if not meta.get("qbit_cat") else meta.get('qbit_cat') content_layout = client.get('content_layout', 'Original') - - qbt_client.torrents_add(torrent_files=torrent.dump(), save_path=path, use_auto_torrent_management=auto_management, is_skip_checking=True, content_layout=content_layout, category=qbt_category) - # Wait for up to 30 seconds for qbit to actually return the download - # there's an async race conditiion within qbt that it will return ok before the torrent is actually added - for _ in range(0, 30): - if len(qbt_client.torrents_info(torrent_hashes=torrent.infohash)) > 0: - break + if meta['debug']: + console.print("qbt_category:", qbt_category) + console.print(f"Content Layout: {content_layout}") + console.print(f"[bold yellow]qBittorrent save path: {save_path}") + + try: + if proxy_url: + # Create FormData for multipart/form-data request + data = aiohttp.FormData() + data.add_field('savepath', save_path) + data.add_field('autoTMM', str(auto_management).lower()) + data.add_field('skip_checking', 'true') + data.add_field('contentLayout', content_layout) + if qbt_category: + data.add_field('category', qbt_category) + data.add_field('torrents', torrent.dump(), filename='torrent.torrent', content_type='application/x-bittorrent') + + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/add", + data=data) as response: + if response.status != 200: + console.print(f"[bold red]Failed to add torrent via proxy: {response.status}") + return + else: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_add, + torrent_files=torrent.dump(), + save_path=save_path, + use_auto_torrent_management=auto_management, + is_skip_checking=True, + content_layout=content_layout, + category=qbt_category), + "Add torrent to qBittorrent", + initial_timeout=14.0 + ) + except (asyncio.TimeoutError, qbittorrentapi.APIConnectionError): + console.print("[bold red]Failed to add torrent to qBittorrent") + if qbt_session: + await qbt_session.close() + return + except Exception as e: + console.print(f"[bold red]Error adding torrent: {e}") + if qbt_session: + await qbt_session.close() + return + + # Wait for torrent to be added + timeout = 30 + for _ in range(timeout): + try: + if proxy_url: + async with qbt_session.get(f"{qbt_proxy_url}/api/v2/torrents/info", + params={'hashes': torrent.infohash}) as response: + if response.status == 200: + torrents_info = await response.json() + if len(torrents_info) > 0: + break + else: + pass # Continue waiting + else: + torrents_info = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_info, torrent_hashes=torrent.infohash), + "Check torrent addition", + max_retries=1, + initial_timeout=10.0 + ) + if len(torrents_info) > 0: + break + except asyncio.TimeoutError: + pass # Continue waiting + except Exception: + pass # Continue waiting await asyncio.sleep(1) - qbt_client.torrents_resume(torrent.infohash) - if client.get('qbit_tag', None) != None: - qbt_client.torrents_add_tags(tags=client.get('qbit_tag'), torrent_hashes=torrent.infohash) - if meta.get('qbit_tag') != None: - qbt_client.torrents_add_tags(tags=meta.get('qbit_tag'), torrent_hashes=torrent.infohash) - console.print(f"Added to: {path}") - + else: + console.print("[red]Torrent addition timed out.") + if qbt_session: + await qbt_session.close() + return + try: + if proxy_url: + console.print("[yellow]No qui proxy resume support....") + # async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/resume", + # data={'hashes': torrent.infohash}) as response: + # if response.status != 200: + # console.print(f"[yellow]Failed to resume torrent via proxy: {response.status}") + else: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_resume, torrent.infohash), + "Resume torrent" + ) + except asyncio.TimeoutError: + console.print("[yellow]Failed to resume torrent after retries") + except Exception as e: + console.print(f"[yellow]Error resuming torrent: {e}") + + if client.get("use_tracker_as_tag", False) and tracker: + try: + if proxy_url: + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/addTags", + data={'hashes': torrent.infohash, 'tags': tracker}) as response: + if response.status != 200: + console.print(f"[yellow]Failed to add tracker tag via proxy: {response.status}") + else: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_add_tags, tags=tracker, torrent_hashes=torrent.infohash), + "Add tracker tag", + initial_timeout=10.0 + ) + except asyncio.TimeoutError: + console.print("[yellow]Failed to add tracker tag after retries") + except Exception as e: + console.print(f"[yellow]Error adding tracker tag: {e}") + + if client.get('qbit_tag'): + try: + if proxy_url: + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/addTags", + data={'hashes': torrent.infohash, 'tags': client['qbit_tag']}) as response: + if response.status != 200: + console.print(f"[yellow]Failed to add client tag via proxy: {response.status}") + else: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_add_tags, tags=client['qbit_tag'], torrent_hashes=torrent.infohash), + "Add client tag", + initial_timeout=10.0 + ) + except asyncio.TimeoutError: + console.print("[yellow]Failed to add client tag after retries") + except Exception as e: + console.print(f"[yellow]Error adding client tag: {e}") + + if meta and meta.get('qbit_tag'): + try: + if proxy_url: + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/addTags", + data={'hashes': torrent.infohash, 'tags': meta['qbit_tag']}) as response: + if response.status != 200: + console.print(f"[yellow]Failed to add meta tag via proxy: {response.status}") + else: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_add_tags, tags=meta['qbit_tag'], torrent_hashes=torrent.infohash), + "Add meta tag", + initial_timeout=10.0 + ) + except asyncio.TimeoutError: + console.print("[yellow]Failed to add meta tag after retries") + except Exception as e: + console.print(f"[yellow]Error adding meta tag: {e}") + + if meta['debug']: + try: + if proxy_url: + async with qbt_session.get(f"{qbt_proxy_url}/api/v2/torrents/info", + params={'hashes': torrent.infohash}) as response: + if response.status == 200: + info = await response.json() + if info: + console.print(f"[cyan]Actual qBittorrent save path: {info[0].get('save_path', 'Unknown')}") + else: + console.print("[yellow]No torrent info returned from proxy") + else: + console.print(f"[yellow]Failed to get torrent info via proxy: {response.status}") + else: + info = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_info, torrent_hashes=torrent.infohash), + "Get torrent info for debug", + initial_timeout=10.0 + ) + if info: + console.print(f"[cyan]Actual qBittorrent save path: {info[0].save_path}") + else: + console.print("[yellow]No torrent info returned from qBittorrent") + except asyncio.TimeoutError: + console.print("[yellow]Failed to get torrent info for debug after retries") + except Exception as e: + console.print(f"[yellow]Error getting torrent info for debug: {e}") + + if meta['debug']: + console.print(f"Added to: {save_path}") + + if qbt_session: + await qbt_session.close() def deluge(self, path, torrent_path, torrent, local_path, remote_path, client, meta): client = DelugeRPCClient(client['deluge_url'], int(client['deluge_port']), client['deluge_user'], client['deluge_pass']) # client = LocalDelugeRPCClient() client.connect() - if client.connected == True: - console.print("Connected to Deluge") - isdir = os.path.isdir(path) - #Remote path mount + if client.connected is True: + console.print("Connected to Deluge") + isdir = os.path.isdir(path) # noqa F841 + # Remote path mount if local_path.lower() in path.lower() and local_path.lower() != remote_path.lower(): path = path.replace(local_path, remote_path) path = path.replace(os.sep, '/') - + path = os.path.dirname(path) - client.call('core.add_torrent_file', torrent_path, base64.b64encode(torrent.dump()), {'download_location' : path, 'seed_mode' : True}) + client.call('core.add_torrent_file', torrent_path, base64.b64encode(torrent.dump()), {'download_location': path, 'seed_mode': True}) if meta['debug']: console.print(f"[cyan]Path: {path}") else: console.print("[bold red]Unable to connect to deluge") + def transmission(self, path, torrent, local_path, remote_path, client, meta): + try: + tr_client = transmission_rpc.Client( + protocol=client['transmission_protocol'], + host=client['transmission_host'], + port=int(client['transmission_port']), + username=client['transmission_username'], + password=client['transmission_password'], + path=client.get('transmission_path', "/transmission/rpc") + ) + except Exception: + console.print("[bold red]Unable to connect to transmission") + return + + console.print("Connected to Transmission") + # Remote path mount + if local_path.lower() in path.lower() and local_path.lower() != remote_path.lower(): + path = path.replace(local_path, remote_path) + path = path.replace(os.sep, '/') + + path = os.path.dirname(path) + if meta.get('transmission_label') is not None: + label = [meta['transmission_label']] + elif client.get('transmission_label', None) is not None: + label = [client['transmission_label']] + else: + label = None + tr_client.add_torrent( + torrent=torrent.dump(), + download_dir=path, + labels=label + ) + + if meta['debug']: + console.print(f"[cyan]Path: {path}") def add_fast_resume(self, metainfo, datapath, torrent): """ Add fast resume data to a metafile dict. @@ -357,10 +1353,10 @@ def add_fast_resume(self, metainfo, datapath, torrent): if single: if os.path.isdir(datapath): datapath = os.path.join(datapath, metainfo["info"]["name"]) - files = [Bunch( - path=[os.path.abspath(datapath)], - length=metainfo["info"]["length"], - )] + files = [{ + "path": [os.path.abspath(datapath)], + "length": metainfo["info"]["length"], + }] # Prepare resume data resume = metainfo.setdefault("libtorrent_resume", {}) @@ -385,30 +1381,1058 @@ def add_fast_resume(self, metainfo, datapath, torrent): resume["files"].append(dict( priority=1, mtime=int(os.path.getmtime(filepath)), - completed=(offset+fileinfo["length"]+piece_length-1) // piece_length - - offset // piece_length, + completed=( + (offset + fileinfo["length"] + piece_length - 1) // piece_length - + offset // piece_length + ), )) offset += fileinfo["length"] return metainfo - async def remote_path_map(self, meta): - if meta.get('client', None) == None: + if meta.get('client', None) is None: torrent_client = self.config['DEFAULT']['default_torrent_client'] else: torrent_client = meta['client'] - local_path = list_local_path = self.config['TORRENT_CLIENTS'][torrent_client].get('local_path','/LocalPath') - remote_path = list_remote_path = self.config['TORRENT_CLIENTS'][torrent_client].get('remote_path', '/RemotePath') - if isinstance(local_path, list): - for i in range(len(local_path)): - if os.path.normpath(local_path[i]).lower() in meta['path'].lower(): - list_local_path = local_path[i] - list_remote_path = remote_path[i] - + local_paths = self.config['TORRENT_CLIENTS'][torrent_client].get('local_path', ['/LocalPath']) + remote_paths = self.config['TORRENT_CLIENTS'][torrent_client].get('remote_path', ['/RemotePath']) + + if not isinstance(local_paths, list): + local_paths = [local_paths] + if not isinstance(remote_paths, list): + remote_paths = [remote_paths] + + list_local_path = local_paths[0] + list_remote_path = remote_paths[0] + + for i in range(len(local_paths)): + if os.path.normpath(local_paths[i]).lower() in meta['path'].lower(): + list_local_path = local_paths[i] + list_remote_path = remote_paths[i] + break + local_path = os.path.normpath(list_local_path) remote_path = os.path.normpath(list_remote_path) if local_path.endswith(os.sep): remote_path = remote_path + os.sep - return local_path, remote_path \ No newline at end of file + return local_path, remote_path + + async def get_ptp_from_hash(self, meta, pathed=False): + default_torrent_client = self.config['DEFAULT']['default_torrent_client'] + client = self.config['TORRENT_CLIENTS'][default_torrent_client] + torrent_client = client['torrent_client'] + if torrent_client == 'rtorrent': + await self.get_ptp_from_hash_rtorrent(meta, pathed) + return meta + elif torrent_client == 'qbit': + proxy_url = client.get('qui_proxy_url') + qbt_client = None + qbt_session = None + + if proxy_url: + qbt_session = aiohttp.ClientSession() + qbt_proxy_url = proxy_url.rstrip('/') + else: + qbt_client = qbittorrentapi.Client( + host=client['qbit_url'], + port=client['qbit_port'], + username=client['qbit_user'], + password=client['qbit_pass'], + VERIFY_WEBUI_CERTIFICATE=client.get('VERIFY_WEBUI_CERTIFICATE', True), + REQUESTS_ARGS={'timeout': 10} + ) + + try: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.auth_log_in), + "qBittorrent login" + ) + except (asyncio.TimeoutError, qbittorrentapi.LoginFailed, qbittorrentapi.APIConnectionError): + console.print("[bold red]Failed to login to qBittorrent") + return meta + + info_hash_v1 = meta.get('infohash') + if meta['debug']: + console.print(f"[cyan]Searching for infohash: {info_hash_v1}") + try: + if proxy_url: + async with qbt_session.get(f"{qbt_proxy_url}/api/v2/torrents/info") as response: + if response.status == 200: + torrents_data = await response.json() + + class MockTorrent: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + if not hasattr(self, 'files'): + self.files = [] + if not hasattr(self, 'tracker'): + self.tracker = '' + if not hasattr(self, 'comment'): + self.comment = '' + torrents = [MockTorrent(torrent) for torrent in torrents_data] + else: + console.print(f"[bold red]Failed to get torrents list via proxy: {response.status}") + if qbt_session: + await qbt_session.close() + return meta + else: + torrents = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_info), + "Get torrents list", + initial_timeout=14.0 + ) + except asyncio.TimeoutError: + console.print("[bold red]Getting torrents list timed out after retries") + if qbt_session: + await qbt_session.close() + return meta + except Exception as e: + console.print(f"[bold red]Error getting torrents list: {e}") + if qbt_session: + await qbt_session.close() + return meta + found = False + + folder_id = os.path.basename(meta['path']) + if meta.get('uuid', None) is None: + meta['uuid'] = folder_id + + extracted_torrent_dir = os.path.join(meta.get('base_dir', ''), "tmp", meta.get('uuid', '')) + os.makedirs(extracted_torrent_dir, exist_ok=True) + + for torrent in torrents: + if torrent.get('infohash_v1') == info_hash_v1: + comment = torrent.get('comment', "") + match = None + + if 'torrent_comments' not in meta: + meta['torrent_comments'] = [] + + comment_data = { + 'hash': torrent.get('infohash_v1', ''), + 'name': torrent.get('name', ''), + 'comment': comment, + } + meta['torrent_comments'].append(comment_data) + + if meta.get('debug', False): + console.print(f"[cyan]Stored comment for torrent: {comment[:100]}...") + + if "passthepopcorn.me" in comment: + match = re.search(r'torrentid=(\d+)', comment) + if match: + meta['ptp'] = match.group(1) + elif "https://aither.cc" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['aither'] = match.group(1) + elif "https://lst.gg" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['lst'] = match.group(1) + elif "https://onlyencodes.cc" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['oe'] = match.group(1) + elif "https://blutopia.cc" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['blu'] = match.group(1) + elif "https://upload.cx" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['ulcx'] = match.group(1) + elif "https://hdbits.org" in comment: + match = re.search(r'id=(\d+)', comment) + if match: + meta['hdb'] = match.group(1) + elif "https://broadcasthe.net" in comment: + match = re.search(r'id=(\d+)', comment) + if match: + meta['btn'] = match.group(1) + elif "https://beyond-hd.me" in comment: + match = re.search(r'details/(\d+)', comment) + if match: + meta['bhd'] = match.group(1) + elif "/torrents/" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['huno'] = match.group(1) + + if match: + for tracker in ['ptp', 'bhd', 'btn', 'huno', 'blu', 'aither', 'ulcx', 'lst', 'oe', 'hdb']: + if meta.get(tracker): + console.print(f"[bold cyan]meta updated with {tracker.upper()} ID: {meta[tracker]}") + + if meta.get('torrent_comments') and meta['debug']: + console.print(f"[green]Stored {len(meta['torrent_comments'])} torrent comments for later use") + + if not pathed: + torrent_storage_dir = client.get('torrent_storage_dir') + if not torrent_storage_dir: + # Export .torrent file + torrent_hash = torrent.get('infohash_v1') + if meta.get('debug', False): + console.print(f"[cyan]Exporting .torrent file for hash: {torrent_hash}") + + try: + if proxy_url: + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/export", + data={'hash': torrent_hash}) as response: + if response.status == 200: + torrent_file_content = await response.read() + else: + console.print(f"[red]Failed to export torrent via proxy: {response.status}") + continue + else: + torrent_file_content = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_export, torrent_hash=torrent_hash), + f"Export torrent {torrent_hash}" + ) + torrent_file_path = os.path.join(extracted_torrent_dir, f"{torrent_hash}.torrent") + + with open(torrent_file_path, "wb") as f: + f.write(torrent_file_content) + + # Validate the .torrent file before saving as BASE.torrent + valid, torrent_path = await self.is_valid_torrent(meta, torrent_file_path, torrent_hash, 'qbit', client, print_err=False) + if not valid: + if meta['debug']: + console.print(f"[bold red]Validation failed for {torrent_file_path}") + os.remove(torrent_file_path) # Remove invalid file + else: + await create_base_from_existing_torrent(torrent_file_path, meta['base_dir'], meta['uuid']) + except asyncio.TimeoutError: + console.print(f"[bold red]Failed to export .torrent for {torrent_hash} after retries") + + found = True + break + + if not found: + console.print("[bold red]Matching site torrent with the specified infohash_v1 not found.") + + if qbt_session: + await qbt_session.close() + + return meta + else: + return meta + + async def get_ptp_from_hash_rtorrent(self, meta, pathed=False): + default_torrent_client = self.config['DEFAULT']['default_torrent_client'] + client = self.config['TORRENT_CLIENTS'][default_torrent_client] + torrent_storage_dir = client.get('torrent_storage_dir') + info_hash_v1 = meta.get('infohash') + + if not torrent_storage_dir or not info_hash_v1: + console.print("[yellow]Missing torrent storage directory or infohash") + return meta + + # Normalize info hash format for rTorrent (uppercase) + info_hash_v1 = info_hash_v1.upper().strip() + torrent_path = os.path.join(torrent_storage_dir, f"{info_hash_v1}.torrent") + + # Extract folder ID for use in temporary file path + folder_id = os.path.basename(meta['path']) + if meta.get('uuid', None) is None: + meta['uuid'] = folder_id + + extracted_torrent_dir = os.path.join(meta.get('base_dir', ''), "tmp", meta.get('uuid', '')) + os.makedirs(extracted_torrent_dir, exist_ok=True) + + # Check if the torrent file exists directly + if os.path.exists(torrent_path): + console.print(f"[green]Found matching torrent file: {torrent_path}") + else: + # Try to find the torrent file in storage directory (case insensitive) + found = False + console.print(f"[yellow]Searching for torrent file with hash {info_hash_v1} in {torrent_storage_dir}") + + if os.path.exists(torrent_storage_dir): + for filename in os.listdir(torrent_storage_dir): + if filename.lower().endswith(".torrent"): + file_hash = os.path.splitext(filename)[0] # Remove .torrent extension + if file_hash.upper() == info_hash_v1: + torrent_path = os.path.join(torrent_storage_dir, filename) + found = True + console.print(f"[green]Found torrent file with matching hash: {filename}") + break + + if not found: + console.print(f"[bold red]No torrent file found for hash: {info_hash_v1}") + return meta + + # Parse the torrent file to get the comment + try: + torrent = Torrent.read(torrent_path) + comment = torrent.comment or "" + + # Try to find tracker IDs in the comment + if meta.get('debug'): + console.print(f"[cyan]Torrent comment: {comment}") + + if 'torrent_comments' not in meta: + meta['torrent_comments'] = [] + + comment_data = { + 'hash': torrent.get('infohash_v1', ''), + 'name': torrent.get('name', ''), + 'comment': comment, + } + meta['torrent_comments'].append(comment_data) + + if meta.get('debug', False): + console.print(f"[cyan]Stored comment for torrent: {comment[:100]}...") + + # Handle various tracker URL formats in the comment + if "passthepopcorn.me" in comment: + match = re.search(r'torrentid=(\d+)', comment) + if match: + meta['ptp'] = match.group(1) + elif "https://aither.cc" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['aither'] = match.group(1) + elif "https://lst.gg" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['lst'] = match.group(1) + elif "https://onlyencodes.cc" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['oe'] = match.group(1) + elif "https://blutopia.cc" in comment: + match = re.search(r'/(\d+)$', comment) + if match: + meta['blu'] = match.group(1) + elif "https://hdbits.org" in comment: + match = re.search(r'id=(\d+)', comment) + if match: + meta['hdb'] = match.group(1) + elif "https://broadcasthe.net" in comment: + match = re.search(r'id=(\d+)', comment) + if match: + meta['btn'] = match.group(1) + elif "https://beyond-hd.me" in comment: + match = re.search(r'details/(\d+)', comment) + if match: + meta['bhd'] = match.group(1) + + # If we found a tracker ID, log it + for tracker in ['ptp', 'bhd', 'btn', 'blu', 'aither', 'lst', 'oe', 'hdb']: + if meta.get(tracker): + console.print(f"[bold cyan]meta updated with {tracker.upper()} ID: {meta[tracker]}") + + if meta.get('torrent_comments') and meta['debug']: + console.print(f"[green]Stored {len(meta['torrent_comments'])} torrent comments for later use") + + if not pathed: + valid, resolved_path = await self.is_valid_torrent( + meta, torrent_path, info_hash_v1, 'rtorrent', client, print_err=False + ) + + if valid: + base_torrent_path = os.path.join(extracted_torrent_dir, "BASE.torrent") + + try: + await create_base_from_existing_torrent(resolved_path, meta['base_dir'], meta['uuid']) + if meta['debug']: + console.print("[green]Created BASE.torrent from existing torrent") + except Exception as e: + console.print(f"[bold red]Error creating BASE.torrent: {e}") + try: + shutil.copy2(resolved_path, base_torrent_path) + console.print(f"[yellow]Created simple torrent copy as fallback: {base_torrent_path}") + except Exception as copy_err: + console.print(f"[bold red]Failed to create backup copy: {copy_err}") + + except Exception as e: + console.print(f"[bold red]Error reading torrent file: {e}") + console.print(f"[dim]{traceback.format_exc()}[/dim]") + + return meta + + async def get_pathed_torrents(self, path, meta): + try: + matching_torrents = await self.find_qbit_torrents_by_path(path, meta) + + # If we found matches, use the hash from the first exact match + if matching_torrents: + exact_matches = [t for t in matching_torrents] + if exact_matches: + meta['infohash'] = exact_matches[0]['hash'] + if meta['debug']: + console.print(f"[green]Found exact torrent match with hash: {meta['infohash']}") + + else: + if meta['debug']: + console.print("[yellow]No matching torrents for the path found in qBittorrent[/yellow]") + + except asyncio.TimeoutError: + raise + except Exception as e: + console.print(f"[red]Error searching for torrents: {str(e)}[/red]") + console.print(f"[dim]{traceback.format_exc()}[/dim]") + + async def find_qbit_torrents_by_path(self, content_path, meta): + if meta.get('debug'): + console.print(f"[yellow]Searching for torrents in qBittorrent for path: {content_path}[/yellow]") + try: + if meta.get('client', None) is None: + default_torrent_client = self.config['DEFAULT']['default_torrent_client'] + else: + default_torrent_client = meta['client'] + if meta.get('client', None) == 'none': + return + if default_torrent_client == "none": + return + client_config = self.config['TORRENT_CLIENTS'][default_torrent_client] + torrent_client = client_config['torrent_client'] + + if torrent_client != 'qbit': + return [] + + tracker_patterns = { + 'ptp': {"url": "passthepopcorn.me", "pattern": r'torrentid=(\d+)'}, + 'aither': {"url": "https://aither.cc", "pattern": r'/(\d+)$'}, + 'lst': {"url": "https://lst.gg", "pattern": r'/(\d+)$'}, + 'oe': {"url": "https://onlyencodes.cc", "pattern": r'/(\d+)$'}, + 'blu': {"url": "https://blutopia.cc", "pattern": r'/(\d+)$'}, + 'hdb': {"url": "https://hdbits.org", "pattern": r'id=(\d+)'}, + 'btn': {"url": "https://broadcasthe.net", "pattern": r'id=(\d+)'}, + 'bhd': {"url": "https://beyond-hd.me", "pattern": r'details/(\d+)'}, + 'huno': {"url": "https://hawke.uno", "pattern": r'/(\d+)$'}, + 'ulcx': {"url": "https://upload.cx", "pattern": r'/(\d+)$'}, + 'rf': {"url": "https://reelflix.xyz", "pattern": r'/(\d+)$'}, + 'otw': {"url": "https://oldtoons.world", "pattern": r'/(\d+)$'}, + 'yus': {"url": "https://yu-scene.net", "pattern": r'/(\d+)$'}, + 'dp': {"url": "https://darkpeers.org", "pattern": r'/(\d+)$'}, + 'sp': {"url": "https://seedpool.org", "pattern": r'/(\d+)$'}, + } + + tracker_priority = ['aither', 'ulcx', 'lst', 'blu', 'oe', 'btn', 'bhd', 'huno', 'hdb', 'rf', 'otw', 'yus', 'dp', 'sp', 'ptp'] + + proxy_url = client_config.get('qui_proxy_url', '').strip() + if proxy_url: + if meta['debug']: + console.print(f"[cyan]Using proxy URL for qBittorrent: {proxy_url[:25]}") + + try: + session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=10), + connector=aiohttp.TCPConnector(verify_ssl=client_config.get('VERIFY_WEBUI_CERTIFICATE', True)) + ) + + # Store session and URL for later API calls + qbt_session = session + qbt_proxy_url = proxy_url + + except Exception as e: + console.print(f"[bold red]Failed to connect to qBittorrent proxy: {e}") + if 'session' in locals(): + await session.close() + return [] + else: + try: + qbt_client = qbittorrentapi.Client( + host=client_config['qbit_url'], + port=int(client_config['qbit_port']), + username=client_config['qbit_user'], + password=client_config['qbit_pass'], + VERIFY_WEBUI_CERTIFICATE=client_config.get('VERIFY_WEBUI_CERTIFICATE', True), + REQUESTS_ARGS={'timeout': 10} + ) + + try: + await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.auth_log_in), + "qBittorrent login" + ) + except asyncio.TimeoutError: + console.print("[bold red]Connection to qBittorrent timed out after retries") + return [] + + except qbittorrentapi.LoginFailed: + console.print("[bold red]Failed to login to qBittorrent - incorrect credentials") + return [] + + except qbittorrentapi.APIConnectionError: + console.print("[bold red]Failed to connect to qBittorrent - check host/port") + return [] + + try: + if proxy_url: + async with qbt_session.get(f"{qbt_proxy_url}/api/v2/torrents/info") as response: + if response.status == 200: + torrents_data = await response.json() + # Convert to objects that match qbittorrentapi structure + + class MockTorrent: + def __init__(self, data): + for key, value in data.items(): + setattr(self, key, value) + if not hasattr(self, 'files'): + self.files = [] + if not hasattr(self, 'tracker'): + self.tracker = '' + if not hasattr(self, 'comment'): + self.comment = '' + torrents = [MockTorrent(torrent) for torrent in torrents_data] + else: + console.print(f"[bold red]Failed to get torrents list via proxy: {response.status}") + return [] + else: + torrents = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_info), + "Get torrents list", + initial_timeout=14.0 + ) + except asyncio.TimeoutError: + console.print("[bold red]Getting torrents list timed out after retries") + return [] + except Exception as e: + console.print(f"[bold red]Error getting torrents list: {e}") + return [] + + if meta['debug']: + console.print(f"[cyan]Found {len(torrents)} torrents in qBittorrent") + + matching_torrents = [] + + # First collect exact path matches + for torrent in torrents: + try: + torrent_name = torrent.name + if not torrent_name: + if meta['debug']: + console.print("[yellow]Skipping torrent with missing name attribute") + continue + + is_match = False + + # Match logic for single files vs disc/multi-file + # Add a fallback default value for meta['is_disc'] + is_disc = meta.get('is_disc', "") + + if is_disc in ("", None) and len(meta.get('filelist', [])) == 1: + file_name = os.path.basename(meta['filelist'][0]) + if (torrent_name == file_name) and len(torrent.files) == 1: + is_match = True + elif torrent_name == meta['uuid']: + is_match = True + else: + if torrent_name == meta['uuid']: + is_match = True + + if not is_match: + continue + + has_working_tracker = False + + if is_match: + try: + if proxy_url: + async with qbt_session.get(f"{qbt_proxy_url}/api/v2/torrents/trackers", + params={'hash': torrent.hash}) as response: + if response.status == 200: + torrent_trackers = await response.json() + else: + if meta['debug']: + console.print(f"[yellow]Failed to get trackers for torrent {torrent.name} via proxy: {response.status}") + continue + else: + torrent_trackers = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_trackers, torrent_hash=torrent.hash), + f"Get trackers for torrent {torrent.name}" + ) + except (asyncio.TimeoutError, qbittorrentapi.APIError): + if meta['debug']: + console.print(f"[yellow]Failed to get trackers for torrent {torrent.name} after retries") + continue + except Exception as e: + if meta['debug']: + console.print(f"[yellow]Error getting trackers for torrent {torrent.name}: {e}") + continue + + try: + display_trackers = [] + + # Filter out DHT, PEX, LSD "trackers" + for tracker in torrent_trackers: + if tracker.get('url', []).startswith(('** [DHT]', '** [PeX]', '** [LSD]')): + continue + display_trackers.append(tracker) + + for tracker in display_trackers: + url = tracker.get('url', 'Unknown URL') + status_code = tracker.get('status', 0) + status_text = { + 0: "Disabled", + 1: "Not contacted", + 2: "Working", + 3: "Updating", + 4: "Error" + }.get(status_code, f"Unknown ({status_code})") + + if status_code == 2: + has_working_tracker = True + if meta['debug']: + console.print(f"[green]Tracker working: {url[:15]} - {status_text}") + + elif meta['debug']: + msg = tracker.get('msg', '') + console.print(f"[yellow]Tracker not working: {url[:15]} - {status_text}{f' - {msg}' if msg else ''}") + + except qbittorrentapi.APIError as e: + if meta['debug']: + console.print(f"[red]Error fetching trackers for torrent {torrent.name}: {e}") + continue + + if 'torrent_comments' not in meta: + meta['torrent_comments'] = [] + + await match_tracker_url([url], meta) + + match_info = { + 'hash': torrent.hash, + 'name': torrent.name, + 'save_path': torrent.save_path, + 'content_path': os.path.normpath(os.path.join(torrent.save_path, torrent.name)), + 'size': torrent.size, + 'category': torrent.category, + 'seeders': torrent.num_complete, + 'trackers': url, + 'has_working_tracker': has_working_tracker, + 'comment': torrent.comment, + } + + # Initialize a list for found tracker IDs + tracker_found = False + tracker_urls = [] + + for tracker_id in tracker_priority: + tracker_info = tracker_patterns.get(tracker_id) + if not tracker_info: + continue + + if tracker_info["url"] in torrent.comment and has_working_tracker: + match = re.search(tracker_info["pattern"], torrent.comment) + if match: + tracker_id_value = match.group(1) + tracker_urls.append({ + 'id': tracker_id, + 'tracker_id': tracker_id_value + }) + meta[tracker_id] = tracker_id_value + tracker_found = True + + if torrent.tracker and 'hawke.uno' in torrent.tracker: + # Try to extract torrent ID from the comment first + if has_working_tracker: + huno_id = None + if "/torrents/" in torrent.comment: + match = re.search(r'/torrents/(\d+)', torrent.comment) + if match: + huno_id = match.group(1) + + # If we found an ID, use it + if huno_id: + tracker_urls.append({ + 'id': 'huno', + 'tracker_id': huno_id, + }) + meta['huno'] = huno_id + tracker_found = True + + if torrent.tracker and 'tracker.anthelion.me' in torrent.tracker: + ant_id = 1 + if has_working_tracker: + tracker_urls.append({ + 'id': 'ant', + 'tracker_id': ant_id, + }) + meta['ant'] = ant_id + tracker_found = True + + match_info['tracker_urls'] = tracker_urls + match_info['has_tracker'] = tracker_found + + if tracker_found: + meta['found_tracker_match'] = True + + if meta.get('debug', False): + console.print(f"[cyan]Stored comment for torrent: {torrent.comment[:100]}...") + + meta['torrent_comments'].append(match_info) + matching_torrents.append(match_info) + + except Exception as e: + if meta['debug']: + console.print(f"[yellow]Error processing torrent {torrent.name}: {str(e)}") + continue + + if matching_torrents: + def get_priority_score(torrent): + # Start with a high score for torrents with no matching trackers + priority_score = 100 + + # If torrent has tracker URLs, find the highest priority one + if torrent.get('tracker_urls'): + for tracker_url in torrent['tracker_urls']: + tracker_id = tracker_url.get('id') + if tracker_id in tracker_priority: + # Lower index in priority list = higher priority + score = tracker_priority.index(tracker_id) + priority_score = min(priority_score, score) + + # Return tuple for sorting: (has_working_tracker, tracker_priority, has_tracker) + return ( + not torrent['has_working_tracker'], + priority_score, + not torrent['has_tracker'] + ) + + # Sort matches by the priority score function + matching_torrents.sort(key=get_priority_score) + + if matching_torrents: + # Extract tracker IDs to meta for the best match (first one after sorting) + best_match = matching_torrents[0] + meta['infohash'] = best_match['hash'] + found_valid_torrent = False + + # Always extract tracker IDs from the best match + if best_match['has_tracker']: + for tracker in best_match['tracker_urls']: + if tracker.get('id') and tracker.get('tracker_id'): + meta[tracker['id']] = tracker['tracker_id'] + if meta['debug']: + console.print(f"[bold cyan]Found {tracker['id'].upper()} ID: {tracker['tracker_id']} in torrent comment") + + if not meta.get('base_torrent_created'): + default_torrent_client = self.config['DEFAULT']['default_torrent_client'] + client = self.config['TORRENT_CLIENTS'][default_torrent_client] + torrent_client = client['torrent_client'] + torrent_storage_dir = client.get('torrent_storage_dir') + + extracted_torrent_dir = os.path.join(meta.get('base_dir', ''), "tmp", meta.get('uuid', '')) + os.makedirs(extracted_torrent_dir, exist_ok=True) + + # Try the best match first + torrent_hash = best_match['hash'] + torrent_file_path = None + + if torrent_storage_dir: + potential_path = os.path.join(torrent_storage_dir, f"{torrent_hash}.torrent") + if os.path.exists(potential_path): + torrent_file_path = potential_path + if meta.get('debug', False): + console.print(f"[cyan]Found existing .torrent file: {torrent_file_path}") + + if not torrent_file_path: + if meta.get('debug', False): + console.print(f"[cyan]Exporting .torrent file for hash: {torrent_hash}") + + torrent_file_content = None + if proxy_url: + qbt_proxy_url = proxy_url.rstrip('/') + try: + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/export", + data={'hash': torrent_hash}) as response: + if response.status == 200: + torrent_file_content = await response.read() + else: + console.print(f"[red]Failed to export torrent via proxy: {response.status}") + except Exception as e: + console.print(f"[red]Error exporting torrent via proxy: {e}") + else: + torrent_file_content = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_export, torrent_hash=torrent_hash), + f"Export torrent {torrent_hash}" + ) + if torrent_file_content is not None: + torrent_file_path = os.path.join(extracted_torrent_dir, f"{torrent_hash}.torrent") + + with open(torrent_file_path, "wb") as f: + f.write(torrent_file_content) + + if meta.get('debug', False): + console.print(f"[green]Exported .torrent file to: {torrent_file_path}") + else: + console.print(f"[bold red]Failed to export .torrent for {torrent_hash} after retries") + + if torrent_file_path: + valid, torrent_path = await self.is_valid_torrent(meta, torrent_file_path, torrent_hash, 'qbit', client, print_err=False) + if valid: + try: + await create_base_from_existing_torrent(torrent_file_path, meta['base_dir'], meta['uuid']) + if meta['debug']: + console.print("[green]Created BASE.torrent from existing torrent") + meta['base_torrent_created'] = True + meta['hash_used'] = torrent_hash + found_valid_torrent = True + except Exception as e: + console.print(f"[bold red]Error creating BASE.torrent: {e}") + else: + if meta['debug']: + console.print(f"[bold red]Validation failed for best match torrent {torrent_file_path}") + if os.path.exists(torrent_file_path) and torrent_file_path.startswith(extracted_torrent_dir): + os.remove(torrent_file_path) + + # Try other matches if the best match isn't valid + if meta['debug']: + console.print("[yellow]Trying other torrent matches...") + for torrent_match in matching_torrents[1:]: # Skip the first one since we already tried it + alt_torrent_hash = torrent_match['hash'] + alt_torrent_file_path = None + + if meta.get('debug', False): + console.print(f"[cyan]Trying alternative torrent: {alt_torrent_hash}") + + # Check if alternative torrent file exists in storage directory + if torrent_storage_dir: + alt_potential_path = os.path.join(torrent_storage_dir, f"{alt_torrent_hash}.torrent") + if os.path.exists(alt_potential_path): + alt_torrent_file_path = alt_potential_path + if meta.get('debug', False): + console.print(f"[cyan]Found existing alternative .torrent file: {alt_torrent_file_path}") + + # If not found in storage directory, export from qBittorrent + if not alt_torrent_file_path: + alt_torrent_file_content = None + if proxy_url: + qbt_proxy_url = proxy_url.rstrip('/') + try: + async with qbt_session.post(f"{qbt_proxy_url}/api/v2/torrents/export", + data={'hash': alt_torrent_hash}) as response: + if response.status == 200: + alt_torrent_file_content = await response.read() + else: + console.print(f"[red]Failed to export alternative torrent via proxy: {response.status}") + except Exception as e: + console.print(f"[red]Error exporting alternative torrent via proxy: {e}") + else: + alt_torrent_file_content = await self.retry_qbt_operation( + lambda: asyncio.to_thread(qbt_client.torrents_export, torrent_hash=alt_torrent_hash), + f"Export alternative torrent {alt_torrent_hash}" + ) + if alt_torrent_file_content is not None: + alt_torrent_file_path = os.path.join(extracted_torrent_dir, f"{alt_torrent_hash}.torrent") + + with open(alt_torrent_file_path, "wb") as f: + f.write(alt_torrent_file_content) + + if meta.get('debug', False): + console.print(f"[green]Exported alternative .torrent file to: {alt_torrent_file_path}") + else: + console.print(f"[bold red]Failed to export alternative .torrent for {alt_torrent_hash} after retries") + continue + + # Validate the alternative torrent + if alt_torrent_file_path: + alt_valid, alt_torrent_path = await self.is_valid_torrent( + meta, alt_torrent_file_path, alt_torrent_hash, 'qbit', client, print_err=False + ) + + if alt_valid: + try: + await create_base_from_existing_torrent(alt_torrent_file_path, meta['base_dir'], meta['uuid']) + if meta['debug']: + console.print(f"[green]Created BASE.torrent from alternative torrent {alt_torrent_hash}") + meta['infohash'] = alt_torrent_hash # Update infohash to use the valid torrent + meta['base_torrent_created'] = True + meta['hash_used'] = torrent_hash + found_valid_torrent = True + break + except Exception as e: + console.print(f"[bold red]Error creating BASE.torrent for alternative: {e}") + else: + if meta['debug']: + console.print(f"[yellow]Alternative torrent {alt_torrent_hash} also invalid") + if os.path.exists(alt_torrent_file_path) and alt_torrent_file_path.startswith(extracted_torrent_dir): + os.remove(alt_torrent_file_path) + + if not found_valid_torrent: + if meta['debug']: + console.print("[bold red]No valid torrents found after checking all matches") + meta['we_checked_them_all'] = True + + # Display results summary + if meta['debug']: + if matching_torrents: + console.print(f"[green]Found {len(matching_torrents)} matching torrents") + console.print(f"[green]Torrents with working trackers: {sum(1 for t in matching_torrents if t.get('has_working_tracker', False))}") + else: + console.print(f"[yellow]No matching torrents found for {torrent_name}") + + if proxy_url and 'qbt_session' in locals(): + await qbt_session.close() + + return matching_torrents + + except asyncio.TimeoutError: + if proxy_url and 'qbt_session' in locals(): + await qbt_session.close() + raise + except Exception as e: + console.print(f"[bold red]Error finding torrents: {str(e)}") + if meta['debug']: + console.print(traceback.format_exc()) + if proxy_url and 'qbt_session' in locals(): + await qbt_session.close() + return [] + + +async def async_link_directory(src, dst, use_hardlink=True, debug=False): + try: + # Create destination directory + await asyncio.to_thread(os.makedirs, os.path.dirname(dst), exist_ok=True) + + # Check if destination already exists + if await asyncio.to_thread(os.path.exists, dst): + if debug: + console.print(f"[yellow]Skipping linking, path already exists: {dst}") + return True + + # Handle file linking + if await asyncio.to_thread(os.path.isfile, src): + if use_hardlink: + try: + await asyncio.to_thread(os.link, src, dst) + if debug: + console.print(f"[green]Hard link created: {dst} -> {src}") + return True + except OSError as e: + console.print(f"[yellow]Hard link failed: {e}") + return False + else: # Use symlink + try: + if platform.system() == "Windows": + await asyncio.to_thread(os.symlink, src, dst, target_is_directory=False) + else: + await asyncio.to_thread(os.symlink, src, dst) + + if debug: + console.print(f"[green]Symbolic link created: {dst} -> {src}") + return True + except OSError as e: + console.print(f"[yellow]Symlink failed: {e}") + return False + + # Handle directory linking + else: + if use_hardlink: + # For hardlinks, we need to recreate the directory structure + await asyncio.to_thread(os.makedirs, dst, exist_ok=True) + + # Get all files in the source directory + all_items = [] + for root, dirs, files in await asyncio.to_thread(os.walk, src): + for file in files: + src_path = os.path.join(root, file) + rel_path = os.path.relpath(src_path, src) + all_items.append((src_path, os.path.join(dst, rel_path), rel_path)) + + # Create subdirectories first (to avoid race conditions) + subdirs = set() + for _, dst_path, _ in all_items: + subdir = os.path.dirname(dst_path) + if subdir and subdir not in subdirs: + subdirs.add(subdir) + await asyncio.to_thread(os.makedirs, subdir, exist_ok=True) + + # Create hardlinks for all files + success = True + for src_path, dst_path, rel_path in all_items: + try: + await asyncio.to_thread(os.link, src_path, dst_path) + if debug and rel_path == os.path.relpath(all_items[0][0], src): + console.print(f"[green]Hard link created for file: {dst_path} -> {src_path}") + except OSError as e: + console.print(f"[yellow]Hard link failed for file {rel_path}: {e}") + success = False + break + + return success + else: + # For symlinks, just link the directory itself + try: + if platform.system() == "Windows": + await asyncio.to_thread(os.symlink, src, dst, target_is_directory=True) + else: + await asyncio.to_thread(os.symlink, src, dst) + + if debug: + console.print(f"[green]Symbolic link created: {dst} -> {src}") + return True + except OSError as e: + console.print(f"[yellow]Symlink failed: {e}") + return False + + except Exception as e: + console.print(f"[bold red]Error during linking: {e}") + return False + + +async def match_tracker_url(tracker_urls, meta): + tracker_url_patterns = { + 'aither': ["https://aither.cc"], + 'ant': ["tracker.anthelion.me"], + 'ar': ["tracker.alpharatio"], + 'asc': ["amigos-share.club"], + 'az': ["tracker.avistaz.to"], + 'bhd': ["https://beyond-hd.me", "tracker.beyond-hd.me"], + 'bjs': ["tracker.bj-share.info"], + 'blu': ["https://blutopia.cc"], + 'bt': ["t.brasiltracker.org"], + 'btn': ["https://broadcasthe.net"], + 'cbr': ["capybarabr.com"], + 'dc': ["tracker.digitalcore.club", "trackerprxy.digitalcore.club"], + 'dp': ["https://darkpeers.org"], + 'ff': ["tracker.funfile.org"], + 'fl': ["reactor.filelist"], + 'fnp': ["https://fearnopeer.com"], + 'hdb': ["https://hdbits.org"], + 'hds': ["hd-space.pw"], + 'hdt': ["https://hdts-announce.ru"], + 'hhd': ["https://homiehelpdesk.net"], + 'huno': ["https://hawke.uno"], + 'lcd': ["locadora.cc"], + 'ldu': ["theldu.to"], + 'lst': ["https://lst.gg"], + 'mtv': ["tracker.morethantv"], + 'nbl': ["tracker.nebulance"], + 'oe': ["https://onlyencodes.cc"], + 'otw': ["https://oldtoons.world"], + 'phd': ["tracker.privatehd"], + 'ptp': ["passthepopcorn.me"], + 'rf': ["https://reelflix.xyz"], + 'rtf': ["peer.retroflix"], + 'sp': ["https://seedpool.org"], + 'spd': ["ramjet.speedapp.io", "ramjet.speedapp.to", "ramjet.speedappio.org"], + 'thr': ["torrenthr"], + 'tl': ["tracker.tleechreload", "tracker.torrentleech"], + 'ulcx': ["https://upload.cx"], + 'yoink': ["yoinked.org"], + 'yus': ["https://yu-scene.net"], + } + found_ids = set() + for tracker in tracker_urls: + for tracker_id, patterns in tracker_url_patterns.items(): + for pattern in patterns: + if pattern in tracker: + found_ids.add(tracker_id.upper()) + if meta.get('debug'): + console.print(f"[bold cyan]Matched {tracker_id.upper()} in tracker URL: {redact_private_info(tracker)}") + + if "remove_trackers" not in meta or not isinstance(meta["remove_trackers"], list): + meta["remove_trackers"] = [] + + for tracker_id in found_ids: + if tracker_id not in meta["remove_trackers"]: + meta["remove_trackers"].append(tracker_id) + if meta.get('debug'): + console.print(f"[bold cyan]Storing matched tracker IDs for later removal: {meta['remove_trackers']}") diff --git a/src/console.py b/src/console.py index 61aeecb04..223c51181 100644 --- a/src/console.py +++ b/src/console.py @@ -1,2 +1,2 @@ -from rich.console import Console -console = Console() \ No newline at end of file +from rich.console import Console +console = Console() diff --git a/src/discparse.py b/src/discparse.py index 33d9b8c68..d110525df 100644 --- a/src/discparse.py +++ b/src/discparse.py @@ -1,104 +1,353 @@ import os -import shutil -import traceback import sys import asyncio +import shutil +import traceback from glob import glob from pymediainfo import MediaInfo from collections import OrderedDict import json - +from pyparsebluray import mpls +from xml.etree import ElementTree as ET +import re +from langcodes import Language +from collections import defaultdict +import platform from src.console import console - - +from data.config import config + + class DiscParse(): def __init__(self): + self.config = config pass """ Get and parse bdinfo """ - async def get_bdinfo(self, discs, folder_id, base_dir, meta_discs): + + async def get_bdinfo(self, meta, discs, folder_id, base_dir, meta_discs): + use_largest = int(self.config['DEFAULT'].get('use_largest_playlist', False)) save_dir = f"{base_dir}/tmp/{folder_id}" if not os.path.exists(save_dir): os.mkdir(save_dir) + + if meta.get('emby', False): + return discs, meta_discs + for i in range(len(discs)): bdinfo_text = None path = os.path.abspath(discs[i]['path']) for file in os.listdir(save_dir): if file == f"BD_SUMMARY_{str(i).zfill(2)}.txt": bdinfo_text = save_dir + "/" + file - if bdinfo_text == None or meta_discs == []: - if os.path.exists(f"{save_dir}/BD_FULL_{str(i).zfill(2)}.txt"): - bdinfo_text = os.path.abspath(f"{save_dir}/BD_FULL_{str(i).zfill(2)}.txt") + if bdinfo_text is None or meta_discs == []: + bdinfo_text = "" + playlists_path = os.path.join(path, "PLAYLIST") + + if not os.path.exists(playlists_path): + console.print(f"[bold red]PLAYLIST directory not found for disc {path}") + continue + + # Parse playlists + valid_playlists = [] + for file_name in os.listdir(playlists_path): + if file_name.endswith(".mpls"): + mpls_path = os.path.join(playlists_path, file_name) + try: + with open(mpls_path, "rb") as mpls_file: + header = mpls.load_movie_playlist(mpls_file) + mpls_file.seek(header.playlist_start_address, os.SEEK_SET) + playlist_data = mpls.load_playlist(mpls_file) + + duration = 0 + items = [] # Collect .m2ts file paths and sizes + stream_directory = os.path.join(path, "STREAM") + file_counts = defaultdict(int) # Tracks the count of each .m2ts file + file_sizes = {} # Stores the size of each unique .m2ts file + + for item in playlist_data.play_items: + duration += (item.outtime - item.intime) / 45000 + try: + m2ts_file = os.path.join(stream_directory, item.clip_information_filename.strip() + ".m2ts") + if os.path.exists(m2ts_file): + size = os.path.getsize(m2ts_file) + file_counts[m2ts_file] += 1 # Increment the count + file_sizes[m2ts_file] = size # Store individual file size + except AttributeError as e: + console.print(f"[bold red]Error accessing clip information for item in {file_name}: {e}") + + # Process unique playlists with only one instance of each file + if all(count == 1 for count in file_counts.values()): + items = [{"file": file, "size": file_sizes[file]} for file in file_counts] + + # Save playlists with duration >= 10 minutes + if duration >= 600: + valid_playlists.append({ + "file": file_name, + "duration": duration, + "path": mpls_path, + "items": items + }) + except Exception as e: + console.print(f"[bold red]Error parsing playlist {mpls_path}: {e}") + + if not valid_playlists: + # Find all playlists regardless of duration + all_playlists = [] + for file_name in os.listdir(playlists_path): + if file_name.endswith(".mpls"): + mpls_path = os.path.join(playlists_path, file_name) + try: + with open(mpls_path, "rb") as mpls_file: + header = mpls.load_movie_playlist(mpls_file) + mpls_file.seek(header.playlist_start_address, os.SEEK_SET) + playlist_data = mpls.load_playlist(mpls_file) + + duration = 0 + items = [] + stream_directory = os.path.join(path, "STREAM") + file_counts = defaultdict(int) + file_sizes = {} + + for item in playlist_data.play_items: + duration += (item.outtime - item.intime) / 45000 + try: + m2ts_file = os.path.join(stream_directory, item.clip_information_filename.strip() + ".m2ts") + if os.path.exists(m2ts_file): + size = os.path.getsize(m2ts_file) + file_counts[m2ts_file] += 1 + file_sizes[m2ts_file] = size + except AttributeError as e: + console.print(f"[bold red]Error accessing clip info for item in {file_name}: {e}") + + if all(count == 1 for count in file_counts.values()): + items = [{"file": file, "size": file_sizes[file]} for file in file_counts] + all_playlists.append({ + "file": file_name, + "duration": duration, + "path": mpls_path, + "items": items + }) + except Exception as e: + console.print(f"[bold red]Error parsing playlist {mpls_path}: {e}") + + if all_playlists: + console.print("[yellow]Using available playlists with any duration") + # Select the largest playlist by total size + largest_playlist = max(all_playlists, key=lambda p: sum(item['size'] for item in p['items'])) + console.print(f"[green]Selected largest playlist {largest_playlist['file']} with duration {largest_playlist['duration']:.2f} seconds") + valid_playlists = [largest_playlist] + else: + console.print(f"[bold red]No playlists found for disc {path}") + continue + + if use_largest: + console.print("[yellow]Auto-selecting the largest playlist based on configuration.") + selected_playlists = [max(valid_playlists, key=lambda p: sum(item['size'] for item in p['items']))] else: - bdinfo_text = "" - if sys.platform.startswith('linux') or sys.platform.startswith('darwin'): + # Allow user to select playlists + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + if len(valid_playlists) == 1: + console.print("[yellow]Only one valid playlist found. Automatically selecting.") + selected_playlists = valid_playlists + else: + while True: # Loop until valid input is provided + console.print("[bold green]Available playlists:") + for idx, playlist in enumerate(valid_playlists): + duration_str = f"{int(playlist['duration'] // 3600)}h {int((playlist['duration'] % 3600) // 60)}m {int(playlist['duration'] % 60)}s" + items_str = ', '.join(f"{os.path.basename(item['file'])} ({item['size'] // (1024 * 1024)} MB)" for item in playlist['items']) + console.print(f"[{idx}] {playlist['file']} - {duration_str} - {items_str}") + + console.print("[bold yellow]Enter playlist numbers separated by commas, 'ALL' to select all, or press Enter to select the biggest playlist:") + user_input = input("Select playlists: ").strip() + + if user_input.lower() == "all": + selected_playlists = valid_playlists + break + elif user_input == "": + # Select the playlist with the largest total size + console.print("[yellow]Selecting the playlist with the largest size:") + selected_playlists = [max(valid_playlists, key=lambda p: sum(item['size'] for item in p['items']))] + break + else: + try: + selected_indices = [int(x) for x in user_input.split(',')] + selected_playlists = [valid_playlists[idx] for idx in selected_indices if 0 <= idx < len(valid_playlists)] + break + except ValueError: + console.print("[bold red]Invalid input. Please try again.") + else: + # Automatically select the largest playlist if unattended without confirmation + console.print("[yellow]Auto-selecting the largest playlist based on unattended configuration.") + selected_playlists = [max(valid_playlists, key=lambda p: sum(item['size'] for item in p['items']))] + + for idx, playlist in enumerate(selected_playlists): + console.print(f"[bold green]Scanning playlist {playlist['file']} with duration {int(playlist['duration'] // 3600)} hours {int((playlist['duration'] % 3600) // 60)} minutes {int(playlist['duration'] % 60)} seconds") + playlist_number = playlist['file'].replace(".mpls", "") + playlist_report_path = os.path.join(save_dir, f"Disc{i + 1}_{playlist_number}_FULL.txt") + + if os.path.exists(playlist_report_path): + bdinfo_text = playlist_report_path + else: try: - # await asyncio.subprocess.Process(['mono', "bin/BDInfo/BDInfo.exe", "-w", path, save_dir]) - console.print(f"[bold green]Scanning {path}") - proc = await asyncio.create_subprocess_exec('mono', f"{base_dir}/bin/BDInfo/BDInfo.exe", '-w', path, save_dir) + # Scanning playlist block (as before) + if sys.platform.startswith('linux') or sys.platform.startswith('darwin'): + proc = await asyncio.create_subprocess_exec( + 'mono', f"{base_dir}/bin/BDInfo/BDInfo.exe", path, '-m', playlist['file'], save_dir + ) + elif sys.platform.startswith('win32'): + proc = await asyncio.create_subprocess_exec( + f"{base_dir}/bin/BDInfo/BDInfo.exe", '-m', playlist['file'], path, save_dir + ) + else: + console.print("[red]Unsupported platform for BDInfo.") + continue + await proc.wait() - except: - console.print('[bold red]mono not found, please install mono') - - elif sys.platform.startswith('win32'): - # await asyncio.subprocess.Process(["bin/BDInfo/BDInfo.exe", "-w", path, save_dir]) - console.print(f"[bold green]Scanning {path}") - proc = await asyncio.create_subprocess_exec(f"{base_dir}/bin/BDInfo/BDInfo.exe", "-w", path, save_dir) - await proc.wait() - await asyncio.sleep(1) - else: - console.print("[red]Not sure how to run bdinfo on your platform, get support please thanks.") - while True: - try: - if bdinfo_text == "": + + # Rename the output to playlist_report_path for file in os.listdir(save_dir): - if file.startswith(f"BDINFO"): - bdinfo_text = save_dir + "/" + file - with open(bdinfo_text, 'r') as f: - text = f.read() - result = text.split("QUICK SUMMARY:", 2) - files = result[0].split("FILES:", 2)[1].split("CHAPTERS:", 2)[0].split("-------------") - result2 = result[1].rstrip(" \n") - result = result2.split("********************", 1) - bd_summary = result[0].rstrip(" \n") - f.close() - with open(bdinfo_text, 'r') as f: # parse extended BDInfo - text = f.read() - result = text.split("[code]", 3) - result2 = result[2].rstrip(" \n") - result = result2.split("FILES:", 1) - ext_bd_summary = result[0].rstrip(" \n") - f.close() + if file.startswith("BDINFO") and file.endswith(".txt"): + bdinfo_text = os.path.join(save_dir, file) + shutil.move(bdinfo_text, playlist_report_path) + bdinfo_text = playlist_report_path # Update bdinfo_text to the renamed file + break + except Exception as e: + console.print(f"[bold red]Error scanning playlist {playlist['file']}: {e}") + continue + + # Process the BDInfo report in the while True loop + while True: try: - shutil.copyfile(bdinfo_text, f"{save_dir}/BD_FULL_{str(i).zfill(2)}.txt") - os.remove(bdinfo_text) - except shutil.SameFileError: - pass - except Exception: - console.print(traceback.format_exc()) - await asyncio.sleep(5) - continue - break - with open(f"{save_dir}/BD_SUMMARY_{str(i).zfill(2)}.txt", 'w') as f: - f.write(bd_summary.strip()) - f.close() - with open(f"{save_dir}/BD_SUMMARY_EXT.txt", 'w') as f: # write extended BDInfo file - f.write(ext_bd_summary.strip()) - f.close() - - bdinfo = self.parse_bdinfo(bd_summary, files[1], path) - - discs[i]['summary'] = bd_summary.strip() - discs[i]['bdinfo'] = bdinfo - # shutil.rmtree(f"{base_dir}/tmp") + if not os.path.exists(bdinfo_text): + console.print(f"[bold red]No valid BDInfo file found for playlist {playlist_number}.") + break + + with open(bdinfo_text, 'r', encoding="utf-8", errors="replace") as f: + text = f.read() + result = text.split("QUICK SUMMARY:", 2) + files = result[0].split("FILES:", 2)[1].split("CHAPTERS:", 2)[0].split("-------------") + result2 = result[1].rstrip(" \n") + result = result2.split("********************", 1) + bd_summary = result[0].rstrip(" \n") + + with open(bdinfo_text, 'r', encoding="utf-8", errors="replace") as f: + text = f.read() + result = text.split("[code]", 3) + result2 = result[2].rstrip(" \n") + result = result2.split("FILES:", 1) + ext_bd_summary = result[0].rstrip(" \n") + + # Save summaries and bdinfo for each playlist + if idx == 0: + summary_file = f"{save_dir}/BD_SUMMARY_{str(i).zfill(2)}.txt" + extended_summary_file = f"{save_dir}/BD_SUMMARY_EXT_{str(i).zfill(2)}.txt" + else: + summary_file = f"{save_dir}/BD_SUMMARY_{str(i).zfill(2)}_{idx}.txt" + extended_summary_file = f"{save_dir}/BD_SUMMARY_EXT_{str(i).zfill(2)}_{idx}.txt" + + # Strip multiple spaces to single spaces before saving + bd_summary_cleaned = re.sub(r' +', ' ', bd_summary.strip()) + ext_bd_summary_cleaned = re.sub(r' +', ' ', ext_bd_summary.strip()) + + with open(summary_file, 'w', encoding="utf-8", errors="replace") as f: + f.write(bd_summary_cleaned) + with open(extended_summary_file, 'w', encoding="utf-8", errors="replace") as f: + f.write(ext_bd_summary_cleaned) + + bdinfo = self.parse_bdinfo(bd_summary, files[1], path) + + # Prompt user for custom edition if conditions are met + if len(selected_playlists) > 1: + current_label = bdinfo.get('label', f"Playlist {idx}") + console.print(f"[bold yellow]Current label for playlist {playlist['file']}: {current_label}") + + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print("[bold green]You can create a custom Edition for this playlist.") + user_input = input(f"Enter a new Edition title for playlist {playlist['file']} (or press Enter to keep the current label): ").strip() + if user_input: + bdinfo['edition'] = user_input + selected_playlists[idx]['edition'] = user_input + console.print(f"[bold green]Edition updated to: {bdinfo['edition']}") + else: + console.print("[bold yellow]Unattended mode: Custom edition not added.") + + # Save to discs array + if idx == 0: + discs[i]['summary'] = bd_summary.strip() + discs[i]['bdinfo'] = bdinfo + discs[i]['playlists'] = selected_playlists + if valid_playlists and meta['unattended'] and not meta.get('unattended_confirm', False): + simplified_playlists = [{"file": p["file"], "duration": p["duration"]} for p in valid_playlists] + duration_map = {} + + # Store simplified version with only file and duration, keeping only one per unique duration + for playlist in valid_playlists: + rounded_duration = round(playlist["duration"]) + if rounded_duration in duration_map: + continue + + duration_map[rounded_duration] = { + "file": playlist["file"], + "duration": playlist["duration"] + } + + simplified_playlists = list(duration_map.values()) + simplified_playlists.sort(key=lambda x: x["duration"], reverse=True) + discs[i]['all_valid_playlists'] = simplified_playlists + + if meta['debug']: + console.print(f"[cyan]Stored {len(simplified_playlists)} unique playlists by duration (from {len(valid_playlists)} total)") + else: + discs[i][f'summary_{idx}'] = bd_summary.strip() + discs[i][f'bdinfo_{idx}'] = bdinfo + + except Exception: + console.print(traceback.format_exc()) + await asyncio.sleep(5) + continue + break + else: discs = meta_discs - + return discs, discs[0]['bdinfo'] - - + + def parse_bdinfo_files(self, files): + """ + Parse the FILES section of the BDInfo input. + Handles filenames with markers like "(1)" and variable spacing. + """ + bdinfo_files = [] + for line in files.splitlines(): + line = line.strip() # Remove leading/trailing whitespace + if not line: # Skip empty lines + continue + + try: + # Split the line manually by whitespace and account for variable columns + parts = line.split() + if len(parts) < 5: # Ensure the line has enough columns + continue + + # Handle cases where the file name has additional markers like "(1)" + if parts[1].startswith("(") and ")" in parts[1]: + file_name = f"{parts[0]} {parts[1]}" # Combine file name and marker + parts = [file_name] + parts[2:] # Rebuild parts with corrected file name + else: + file_name = parts[0] + + m2ts = { + "file": file_name, + "length": parts[2], # Length is the 3rd column + } + bdinfo_files.append(m2ts) + + except Exception as e: + print(f"Failed to process bdinfo line: {line} -> {e}") + + return bdinfo_files def parse_bdinfo(self, bdinfo_input, files, path): bdinfo = dict() @@ -107,56 +356,56 @@ def parse_bdinfo(self, bdinfo_input, files, path): bdinfo['subtitles'] = list() bdinfo['path'] = path lines = bdinfo_input.splitlines() - for l in lines: + for l in lines: # noqa E741 line = l.strip().lower() if line.startswith("*"): line = l.replace("*", "").strip().lower() if line.startswith("playlist:"): playlist = l.split(':', 1)[1] - bdinfo['playlist'] = playlist.split('.',1)[0].strip() + bdinfo['playlist'] = playlist.split('.', 1)[0].strip() if line.startswith("disc size:"): size = l.split(':', 1)[1] - size = size.split('bytes', 1)[0].replace(',','') - size = float(size)/float(1<<30) + size = size.split('bytes', 1)[0].replace(',', '') + size = float(size) / float(1 << 30) bdinfo['size'] = size if line.startswith("length:"): length = l.split(':', 1)[1] - bdinfo['length'] = length.split('.',1)[0].strip() + bdinfo['length'] = length.split('.', 1)[0].strip() if line.startswith("video:"): split1 = l.split(':', 1)[1] split2 = split1.split('/', 12) while len(split2) != 9: split2.append("") - n=0 + n = 0 if "Eye" in split2[2].strip(): n = 1 three_dim = split2[2].strip() else: three_dim = "" try: - bit_depth = split2[n+6].strip() - hdr_dv = split2[n+7].strip() - color = split2[n+8].strip() - except: + bit_depth = split2[n + 6].strip() + hdr_dv = split2[n + 7].strip() + color = split2[n + 8].strip() + except Exception: bit_depth = "" hdr_dv = "" color = "" bdinfo['video'].append({ - 'codec': split2[0].strip(), - 'bitrate': split2[1].strip(), - 'res': split2[n+2].strip(), - 'fps': split2[n+3].strip(), - 'aspect_ratio' : split2[n+4].strip(), - 'profile': split2[n+5].strip(), - 'bit_depth' : bit_depth, - 'hdr_dv' : hdr_dv, - 'color' : color, - '3d' : three_dim, - }) + 'codec': split2[0].strip(), + 'bitrate': split2[1].strip(), + 'res': split2[n + 2].strip(), + 'fps': split2[n + 3].strip(), + 'aspect_ratio': split2[n + 4].strip(), + 'profile': split2[n + 5].strip(), + 'bit_depth': bit_depth, + 'hdr_dv': hdr_dv, + 'color': color, + '3d': three_dim, + }) elif line.startswith("audio:"): if "(" in l: - l = l.split("(")[0] - l = l.strip() + l = l.split("(")[0] # noqa E741 + l = l.strip() # noqa E741 split1 = l.split(':', 1)[1] split2 = split1.split('/') n = 0 @@ -166,18 +415,18 @@ def parse_bdinfo(self, bdinfo_input, files, path): else: fuckatmos = "" try: - bit_depth = split2[n+5].strip() - except: + bit_depth = split2[n + 5].strip() + except Exception: bit_depth = "" bdinfo['audio'].append({ - 'language' : split2[0].strip(), - 'codec' : split2[1].strip(), - 'channels' : split2[n+2].strip(), - 'sample_rate' : split2[n+3].strip(), - 'bitrate' : split2[n+4].strip(), - 'bit_depth' : bit_depth, # Also DialNorm, but is not in use anywhere yet + 'language': split2[0].strip(), + 'codec': split2[1].strip(), + 'channels': split2[n + 2].strip(), + 'sample_rate': split2[n + 3].strip(), + 'bitrate': split2[n + 4].strip(), + 'bit_depth': bit_depth, # Also DialNorm, but is not in use anywhere yet 'atmos_why_you_be_like_this': fuckatmos, - }) + }) elif line.startswith("disc title:"): title = l.split(':', 1)[1] bdinfo['title'] = title @@ -188,88 +437,613 @@ def parse_bdinfo(self, bdinfo_input, files, path): split1 = l.split(':', 1)[1] split2 = split1.split('/') bdinfo['subtitles'].append(split2[0].strip()) - files = files.splitlines() - bdinfo['files'] = [] + files = self.parse_bdinfo_files(files) + bdinfo['files'] = files for line in files: try: stripped = line.split() m2ts = {} bd_file = stripped[0] - time_in = stripped[1] + time_in = stripped[1] # noqa F841 bd_length = stripped[2] - bd_size = stripped[3] - bd_bitrate = stripped[4] + bd_size = stripped[3] # noqa F841 + bd_bitrate = stripped[4] # noqa F841 m2ts['file'] = bd_file m2ts['length'] = bd_length bdinfo['files'].append(m2ts) - except: + except Exception: pass return bdinfo - - """ Parse VIDEO_TS and get mediainfos """ - async def get_dvdinfo(self, discs): + + async def get_dvdinfo(self, discs, base_dir=None): for each in discs: path = each.get('path') os.chdir(path) - files = glob(f"VTS_*.VOB") + files = glob("VTS_*.VOB") files.sort() - # Switch to ordered dictionary filesdict = OrderedDict() main_set = [] - # Use ordered dictionary in place of list of lists for file in files: trimmed = file[4:] if trimmed[:2] not in filesdict: filesdict[trimmed[:2]] = [] filesdict[trimmed[:2]].append(trimmed) main_set_duration = 0 + mediainfo_binary = os.path.join(base_dir, "bin", "MI", "windows", "MediaInfo.exe") + for vob_set in filesdict.values(): - # Parse media info for this VOB set - vob_set_mi = MediaInfo.parse(f"VTS_{vob_set[0][:2]}_0.IFO", output='JSON') - vob_set_mi = json.loads(vob_set_mi) - vob_set_duration = vob_set_mi['media']['track'][1]['Duration'] - - - # If the duration of the new vob set > main set by more than 10% then it's our new main set - # This should make it so TV shows pick the first episode - if (float(vob_set_duration) * 1.00) > (float(main_set_duration) * 1.10) or len(main_set) < 1: + try: + ifo_file = f"VTS_{vob_set[0][:2]}_0.IFO" + + try: + if platform.system() == "Windows": + process = await asyncio.create_subprocess_exec( + mediainfo_binary, "--Output=JSON", ifo_file, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process and process.returncode == 0: + vob_set_mi = stdout.decode() + else: + vob_set_mi = MediaInfo.parse(ifo_file, output='JSON') + else: + vob_set_mi = MediaInfo.parse(ifo_file, output='JSON') + + except Exception as e: + console.print(f"[yellow]Error with DVD MediaInfo binary: {str(e)}") + # Fall back to standard MediaInfo + vob_set_mi = MediaInfo.parse(ifo_file, output='JSON') + + vob_set_mi = json.loads(vob_set_mi) + tracks = vob_set_mi.get('media', {}).get('track', []) + + if len(tracks) > 1: + vob_set_duration = tracks[1].get('Duration', "Unknown") + else: + console.print("Warning: Expected track[1] is missing.") + vob_set_duration = "Unknown" + + except Exception as e: + console.print(f"Error processing VOB set: {e}") + vob_set_duration = "Unknown" + + if vob_set_duration == "Unknown" or not vob_set_duration.replace('.', '', 1).isdigit(): + console.print(f"Skipping VOB set due to invalid duration: {vob_set_duration}") + continue + + # If the duration of the new vob set > main set by more than 10%, it's the new main set + # This should make it so TV shows pick the first episode + vob_set_duration_float = float(vob_set_duration) + if (vob_set_duration_float * 1.00) > (float(main_set_duration) * 1.10) or len(main_set) < 1: main_set = vob_set - main_set_duration = vob_set_duration + main_set_duration = vob_set_duration_float + each['main_set'] = main_set set = main_set[0][:2] each['vob'] = vob = f"{path}/VTS_{set}_1.VOB" each['ifo'] = ifo = f"{path}/VTS_{set}_0.IFO" - each['vob_mi'] = MediaInfo.parse(os.path.basename(vob), output='STRING', full=False, mediainfo_options={'inform_version' : '1'}).replace('\r\n', '\n') - each['ifo_mi'] = MediaInfo.parse(os.path.basename(ifo), output='STRING', full=False, mediainfo_options={'inform_version' : '1'}).replace('\r\n', '\n') - each['vob_mi_full'] = MediaInfo.parse(vob, output='STRING', full=False, mediainfo_options={'inform_version' : '1'}).replace('\r\n', '\n') - each['ifo_mi_full'] = MediaInfo.parse(ifo, output='STRING', full=False, mediainfo_options={'inform_version' : '1'}).replace('\r\n', '\n') - - size = sum(os.path.getsize(f) for f in os.listdir('.') if os.path.isfile(f))/float(1<<30) + try: + mediainfo_binary = os.path.join(base_dir, "bin", "MI", "windows", "MediaInfo.exe") + + try: + if platform.system() == "Windows": + process = await asyncio.create_subprocess_exec( + mediainfo_binary, os.path.basename(vob), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process and process.returncode == 0: + each['vob_mi'] = stdout.decode().replace('\r\n', '\n') + else: + each['vob_mi'] = MediaInfo.parse(os.path.basename(vob), output='STRING', full=False).replace('\r\n', '\n') + else: + each['vob_mi'] = MediaInfo.parse(os.path.basename(vob), output='STRING', full=False).replace('\r\n', '\n') + except Exception as e: + console.print(f"[yellow]Error with DVD MediaInfo binary for VOB: {str(e)}") + each['vob_mi'] = MediaInfo.parse(os.path.basename(vob), output='STRING', full=False).replace('\r\n', '\n') + + try: + if platform.system() == "Windows": + process = await asyncio.create_subprocess_exec( + mediainfo_binary, os.path.basename(ifo), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process and process.returncode == 0: + each['ifo_mi'] = stdout.decode().replace('\r\n', '\n') + else: + each['ifo_mi'] = MediaInfo.parse(os.path.basename(ifo), output='STRING', full=False).replace('\r\n', '\n') + else: + each['ifo_mi'] = MediaInfo.parse(os.path.basename(ifo), output='STRING', full=False).replace('\r\n', '\n') + except Exception as e: + console.print(f"[yellow]Error with DVD MediaInfo binary for IFO: {str(e)}") + each['ifo_mi'] = MediaInfo.parse(os.path.basename(ifo), output='STRING', full=False).replace('\r\n', '\n') + + try: + if platform.system() == "Windows": + process = await asyncio.create_subprocess_exec( + mediainfo_binary, vob, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process and process.returncode == 0: + each['vob_mi_full'] = stdout.decode().replace('\r\n', '\n') + else: + each['vob_mi_full'] = MediaInfo.parse(vob, output='STRING', full=False).replace('\r\n', '\n') + else: + each['vob_mi_full'] = MediaInfo.parse(vob, output='STRING', full=False).replace('\r\n', '\n') + except Exception as e: + console.print(f"[yellow]Error with DVD MediaInfo binary for full VOB: {str(e)}") + each['vob_mi_full'] = MediaInfo.parse(vob, output='STRING', full=False).replace('\r\n', '\n') + + try: + if platform.system() == "Windows": + process = await asyncio.create_subprocess_exec( + mediainfo_binary, ifo, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + + if process and process.returncode == 0: + each['ifo_mi_full'] = stdout.decode().replace('\r\n', '\n') + else: + each['ifo_mi_full'] = MediaInfo.parse(ifo, output='STRING', full=False).replace('\r\n', '\n') + else: + each['ifo_mi_full'] = MediaInfo.parse(ifo, output='STRING', full=False).replace('\r\n', '\n') + except Exception as e: + console.print(f"[yellow]Error with DVD MediaInfo binary for full IFO: {str(e)}") + each['ifo_mi_full'] = MediaInfo.parse(ifo, output='STRING', full=False).replace('\r\n', '\n') + + except Exception as e: + console.print(f"[yellow]Error using DVD MediaInfo binary, falling back to standard: {e}") + # Fallback to standard MediaInfo + each['vob_mi'] = MediaInfo.parse(os.path.basename(vob), output='STRING', full=False).replace('\r\n', '\n') + each['ifo_mi'] = MediaInfo.parse(os.path.basename(ifo), output='STRING', full=False).replace('\r\n', '\n') + each['vob_mi_full'] = MediaInfo.parse(vob, output='STRING', full=False).replace('\r\n', '\n') + each['ifo_mi_full'] = MediaInfo.parse(ifo, output='STRING', full=False).replace('\r\n', '\n') + + size = sum(os.path.getsize(f) for f in os.listdir('.') if os.path.isfile(f)) / float(1 << 30) + each['disc_size'] = round(size, 2) if size <= 7.95: dvd_size = "DVD9" if size <= 4.37: dvd_size = "DVD5" each['size'] = dvd_size return discs - - async def get_hddvd_info(self, discs): + + async def get_hddvd_info(self, discs, meta): + use_largest = int(self.config['DEFAULT'].get('use_largest_playlist', False)) for each in discs: path = each.get('path') os.chdir(path) - files = glob("*.EVO") - size = 0 - largest = files[0] - # get largest file from files - for file in files: - file_size = os.path.getsize(file) - if file_size > size: - largest = file - size = file_size - each['evo_mi'] = MediaInfo.parse(os.path.basename(largest), output='STRING', full=False, mediainfo_options={'inform_version' : '1'}) - each['largest_evo'] = os.path.abspath(f"{path}/{largest}") + + try: + # Define the playlist path + playlist_path = os.path.join(meta['path'], "ADV_OBJ") + xpl_files = glob(f"{playlist_path}/*.xpl") + if meta['debug']: + console.print(f"Found {xpl_files} in {playlist_path}") + + if not xpl_files: + raise FileNotFoundError(f"No .xpl files found in {playlist_path}") + + # Use the first .xpl file found + playlist_file = xpl_files[0] + playlist_info = self.parse_hddvd_playlist(playlist_file) + + # Filter valid playlists (at least one clip with valid size) + valid_playlists = [] + for playlist in playlist_info: + primary_clips = playlist.get("primaryClips", []) + evo_files = [os.path.abspath(f"{path}/{os.path.basename(clip.get('src').replace('.MAP', '.EVO'))}") + for clip in primary_clips] + total_size = sum(os.path.getsize(evo) for evo in evo_files if os.path.exists(evo)) + if total_size > 0: + playlist["totalSize"] = total_size + playlist["evoFiles"] = evo_files + valid_playlists.append(playlist) + + if not valid_playlists: + raise ValueError("No valid playlists found with accessible .EVO files.") + + if use_largest: + console.print("[yellow]Auto-selecting the largest playlist based on size.") + selected_playlists = [ + max( + valid_playlists, + key=lambda p: p["totalSize"] + ) + ] + elif meta['unattended'] and not meta.get('unattended_confirm', False): + console.print("[yellow]Unattended mode: Auto-selecting the largest playlist.") + selected_playlists = [ + max( + valid_playlists, + key=lambda p: p["totalSize"] + ) + ] + else: + # Allow user to select playlists + while True: + console.print("[cyan]Available playlists:") + for idx, playlist in enumerate(valid_playlists, start=1): + duration = playlist.get("titleDuration", "Unknown") + title_number = playlist.get("titleNumber", "") + playlist_id = playlist.get("id", "") + description = playlist.get("description", "") + total_size = playlist.get("totalSize", 0) + additional_info = [] + if playlist_id: + additional_info.append(f"[yellow]ID:[/yellow] {playlist_id}") + if description: + additional_info.append(f"[yellow]Description:[/yellow] {description}") + additional_info.append(f"[yellow]Size:[/yellow] {total_size / (1024 * 1024):.2f} MB") + additional_info_str = ", ".join(additional_info) + console.print(f"{idx}: Duration: {duration} Playlist: {title_number}" + (f" ({additional_info_str})" if additional_info else "")) + + user_input = input("Enter the number of the playlist you want to select: ").strip() + + try: + selected_indices = [int(x) - 1 for x in user_input.split(",")] + if any(i < 0 or i >= len(valid_playlists) for i in selected_indices): + raise ValueError("Invalid playlist number.") + + selected_playlists = [valid_playlists[i] for i in selected_indices] + break # Exit the loop when valid input is provided + except (ValueError, IndexError): + console.print("[red]Invalid input. Please try again.") + + # Extract the .EVO files from the selected playlists + primary_clips = [] + for playlist in selected_playlists: + primary_clips.extend(playlist.get("primaryClips", [])) + + # Validate that the correct EVO files are being used + for playlist in selected_playlists: + expected_evo_files = playlist.get("evoFiles", []) + if not expected_evo_files or any(not os.path.exists(evo) for evo in expected_evo_files): + raise ValueError(f"Expected EVO files for playlist {playlist['id']} do not exist.") + + # Calculate the total size for the selected playlist + playlist["totalSize"] = sum(os.path.getsize(evo) for evo in expected_evo_files if os.path.exists(evo)) + + # Assign the valid EVO files + playlist["evoFiles"] = expected_evo_files + + if not primary_clips: + raise ValueError("No primary clips found in the selected playlists.") + + selected_playlist = selected_playlists[0] # Assuming you're working with the largest or user-selected playlist + evo_files = selected_playlist["evoFiles"] + total_size = selected_playlist["totalSize"] + + # Overwrite mediainfo File size and Duration + if evo_files: + # Filter out non-existent files + existing_evo_files = [evo for evo in evo_files if os.path.exists(evo)] + + if len(existing_evo_files) >= 2: + # Select the second .EVO file + selected_evo_path = existing_evo_files[1] + else: + # Fallback to the largest file + selected_evo_path = max( + existing_evo_files, + key=os.path.getsize + ) + + if not os.path.exists(selected_evo_path): + raise FileNotFoundError(f"Selected .EVO file {selected_evo_path} does not exist.") + + # Parse MediaInfo for the largest .EVO file + original_mediainfo = MediaInfo.parse(selected_evo_path, output='STRING', full=False) + + modified_mediainfo = re.sub( + r"File size\s+:\s+[^\r\n]+", + f"File size : {total_size / (1024 ** 3):.2f} GiB", + original_mediainfo + ) + modified_mediainfo = re.sub( + r"Duration\s+:\s+[^\r\n]+", + f"Duration : {self.format_duration(selected_playlist['titleDuration'])}", + modified_mediainfo + ) + + # Split MediaInfo into blocks for easier manipulation + mediainfo_blocks = modified_mediainfo.replace("\r\n", "\n").split("\n\n") + + # Add language details to the correct "Audio #X" block + audio_tracks = selected_playlist.get("audioTracks", []) + for audio_track in audio_tracks: + # Extract track information from the playlist + track_number = int(audio_track.get("track", "1")) # Ensure track number is an integer + language = audio_track.get("language", "") + langcode = audio_track.get("langcode", "") + description = audio_track.get("description", "") + + # Debugging: Print the current audio track information + console.print(f"[Debug] Processing Audio Track: {track_number}") + console.print(f" Language: {language}") + console.print(f" Langcode: {langcode}") + + # Find the corresponding "Audio #X" block in MediaInfo + found_block = False + for i, block in enumerate(mediainfo_blocks): + # console.print(mediainfo_blocks) + if re.match(rf"^\s*Audio #\s*{track_number}\b.*", block): # Match the correct Audio # block + found_block = True + console.print(f"[Debug] Found matching MediaInfo block for Audio Track {track_number}.") + + # Check if Language is already present + if language and not re.search(rf"Language\s+:\s+{re.escape(language)}", block): + # Locate "Compression mode" line + compression_mode_index = block.find("Compression mode") + if compression_mode_index != -1: + # Find the end of the "Compression mode" line + line_end = block.find("\n", compression_mode_index) + if line_end == -1: + line_end = len(block) # If no newline, append to the end of the block + + # Construct the new Language entry + language_entry = f"\nLanguage : {language}" + + # Insert the new entry + updated_block = ( + block[:line_end] # Up to the end of the "Compression mode" + + language_entry + + block[line_end:] # Rest of the block + ) + mediainfo_blocks[i] = updated_block + console.print(f"[Debug] Updated MediaInfo Block for Audio Track {track_number}:") + console.print(updated_block) + break # Stop processing once the correct block is modified + + # Debugging: Log if no matching block was found + if not found_block: + console.print(f"[Debug] No matching MediaInfo block found for Audio Track {track_number}.") + + # Add subtitle track languages to the correct "Text #X" block + subtitle_tracks = selected_playlist.get("subtitleTracks", []) + for subtitle_track in subtitle_tracks: + track_number = int(subtitle_track.get("track", "1")) # Ensure track number is an integer + language = subtitle_track.get("language", "") + langcode = subtitle_track.get("langcode", "") + + # Debugging: Print current subtitle track info + console.print(f"[Debug] Processing Subtitle Track: {track_number}") + console.print(f" Language: {language}") + console.print(f" Langcode: {langcode}") + + # Find the corresponding "Text #X" block + found_block = False + for i, block in enumerate(mediainfo_blocks): + if re.match(rf"^\s*Text #\s*{track_number}\b", block): # Match the correct Text # block + found_block = True + console.print(f"[Debug] Found matching MediaInfo block for Subtitle Track {track_number}.") + + # Insert Language details if not already present + if language and not re.search(rf"Language\s+:\s+{re.escape(language)}", block): + # Locate the "Format" line + format_index = block.find("Format") + if format_index != -1: + # Find the end of the "Format" line + insertion_point = block.find("\n", format_index) + if insertion_point == -1: + insertion_point = len(block) # If no newline, append to the end of the block + + # Construct the new Language entry + language_entry = f"\nLanguage : {language}" + + # Insert the new entry + updated_block = ( + block[:insertion_point] # Up to the end of the "Format" line + + language_entry + + block[insertion_point:] # Rest of the block + ) + mediainfo_blocks[i] = updated_block + console.print(f"[Debug] Updated MediaInfo Block for Subtitle Track {track_number}:") + console.print(updated_block) + break # Stop processing once the correct block is modified + + # Debugging: Log if no matching block was found + if not found_block: + console.print(f"[Debug] No matching MediaInfo block found for Subtitle Track {track_number}.") + + # Rejoin the modified MediaInfo blocks + modified_mediainfo = "\n\n".join(mediainfo_blocks) + + # Update the dictionary with the modified MediaInfo and file path + each['evo_mi'] = modified_mediainfo + each['largest_evo'] = selected_evo_path + + # Save playlist information in meta under HDDVD_PLAYLIST + meta["HDDVD_PLAYLIST"] = selected_playlist + + except (FileNotFoundError, ValueError, ET.ParseError) as e: + console.print(f"Playlist processing failed: {e}. Falling back to largest EVO file detection.") + + # Fallback to largest .EVO file + files = glob("*.EVO") + if not files: + console.print("No EVO files found in the directory.") + continue + + size = 0 + largest = files[0] + + # Get largest file from files + for file in files: + file_size = os.path.getsize(file) + if file_size > size: + largest = file + size = file_size + + # Generate MediaInfo for the largest EVO file + each['evo_mi'] = MediaInfo.parse(os.path.basename(largest), output='STRING', full=False) + each['largest_evo'] = os.path.abspath(f"{path}/{largest}") + return discs + + def format_duration(self, timecode): + parts = timecode.split(":") + if len(parts) != 4: + return "Unknown duration" + + hours, minutes, seconds, _ = map(int, parts) + duration = "" + if hours > 0: + duration += f"{hours} h " + if minutes > 0: + duration += f"{minutes} min" + return duration.strip() + + def parse_hddvd_playlist(self, file_path): + titles = [] + try: + # Parse the XML structure + tree = ET.parse(file_path) + root = tree.getroot() + + # Extract namespace + namespace = {'ns': 'http://www.dvdforum.org/2005/HDDVDVideo/Playlist'} + + for title in root.findall(".//ns:Title", namespaces=namespace): + title_duration = title.get("titleDuration", "00:00:00:00") + duration_seconds = self.timecode_to_seconds(title_duration) + + # Skip titles with a duration of 10 minutes or less + if duration_seconds <= 600: + continue + + title_data = { + "titleNumber": title.get("titleNumber"), + "id": title.get("id"), + "description": title.get("description"), + "titleDuration": title_duration, + "displayName": title.get("displayName"), + "onEnd": title.get("onEnd"), + "alternativeSDDisplayMode": title.get("alternativeSDDisplayMode"), + "primaryClips": [], + "chapters": [], + "audioTracks": [], + "subtitleTracks": [], + "applicationSegments": [], + } + + # Extract PrimaryAudioVideoClip details + for clip in title.findall(".//ns:PrimaryAudioVideoClip", namespaces=namespace): + clip_data = { + "src": clip.get("src"), + "titleTimeBegin": clip.get("titleTimeBegin"), + "titleTimeEnd": clip.get("titleTimeEnd"), + "seamless": clip.get("seamless"), + "audioTracks": [], + "subtitleTracks": [], + } + + # Extract Audio tracks within PrimaryAudioVideoClip + for audio in clip.findall(".//ns:Audio", namespaces=namespace): + clip_data["audioTracks"].append({ + "track": audio.get("track"), + "streamNumber": audio.get("streamNumber"), + "mediaAttr": audio.get("mediaAttr"), + "description": audio.get("description"), + }) + + # Extract Subtitle tracks within PrimaryAudioVideoClip + for subtitle in clip.findall(".//ns:Subtitle", namespaces=namespace): + clip_data["subtitleTracks"].append({ + "track": subtitle.get("track"), + "streamNumber": subtitle.get("streamNumber"), + "mediaAttr": subtitle.get("mediaAttr"), + "description": subtitle.get("description"), + }) + + title_data["primaryClips"].append(clip_data) + + # Extract ChapterList details + for chapter in title.findall(".//ns:ChapterList/ns:Chapter", namespaces=namespace): + title_data["chapters"].append({ + "displayName": chapter.get("displayName"), + "titleTimeBegin": chapter.get("titleTimeBegin"), + }) + + # Extract TrackNavigationList details (AudioTracks and SubtitleTracks) + for audio_track in title.findall(".//ns:TrackNavigationList/ns:AudioTrack", namespaces=namespace): + langcode = audio_track.get("langcode", "") + # Extract the 2-letter language code before the colon + langcode_short = langcode.split(":")[0] if ":" in langcode else langcode + # Convert the short language code to the full language name + language_name = Language.get(langcode_short).display_name() + + title_data["audioTracks"].append({ + "track": audio_track.get("track"), + "langcode": langcode_short, + "language": language_name, + "description": audio_track.get("description"), + "selectable": audio_track.get("selectable"), + }) + + for subtitle_track in title.findall(".//ns:TrackNavigationList/ns:SubtitleTrack", namespaces=namespace): + langcode = subtitle_track.get("langcode", "") + # Extract the 2-letter language code before the colon + langcode_short = langcode.split(":")[0] if ":" in langcode else langcode + # Convert the short language code to the full language name + language_name = Language.get(langcode_short).display_name() + + title_data["subtitleTracks"].append({ + "track": subtitle_track.get("track"), + "langcode": langcode_short, + "language": language_name, + "selectable": subtitle_track.get("selectable"), + }) + + # Extract ApplicationSegment details + for app_segment in title.findall(".//ns:ApplicationSegment", namespaces=namespace): + app_data = { + "src": app_segment.get("src"), + "titleTimeBegin": app_segment.get("titleTimeBegin"), + "titleTimeEnd": app_segment.get("titleTimeEnd"), + "sync": app_segment.get("sync"), + "zOrder": app_segment.get("zOrder"), + "resources": [], + } + + # Extract ApplicationResource details + for resource in app_segment.findall(".//ns:ApplicationResource", namespaces=namespace): + app_data["resources"].append({ + "src": resource.get("src"), + "size": resource.get("size"), + "priority": resource.get("priority"), + "multiplexed": resource.get("multiplexed"), + }) + + title_data["applicationSegments"].append(app_data) + + # Add the fully extracted title data to the list + titles.append(title_data) + + except ET.ParseError as e: + print(f"Error parsing XPL file: {e}") + return titles + + def timecode_to_seconds(self, timecode): + parts = timecode.split(":") + if len(parts) != 4: + return 0 + hours, minutes, seconds, frames = map(int, parts) + return hours * 3600 + minutes * 60 + seconds diff --git a/src/dupe_checking.py b/src/dupe_checking.py new file mode 100644 index 000000000..1a238ab6e --- /dev/null +++ b/src/dupe_checking.py @@ -0,0 +1,379 @@ +import os +import re +from data.config import config +from src.console import console +from src.trackers.HUNO import HUNO + + +async def filter_dupes(dupes, meta, tracker_name): + """ + Filter duplicates by applying exclusion rules. Only non-excluded entries are returned. + Everything is a dupe, until it matches a criteria to be excluded. + """ + if meta['debug']: + console.log(f"[cyan]Pre-filtered dupes from {tracker_name}") + console.log(dupes) + meta['trumpable'] = False + processed_dupes = [] + for d in dupes: + if isinstance(d, str): + # Case 1: Simple string (just name) + processed_dupes.append({'name': d, 'size': None, 'files': [], 'file_count': 0, 'trumpable': False, 'link': None}) + elif isinstance(d, dict): + # Create a base entry with default values + entry = { + 'name': d.get('name', ''), + 'size': d.get('size'), + 'files': [], + 'file_count': 0, + 'trumpable': d.get('trumpable', False), + 'link': d.get('link', None) + } + + # Case 3: Dict with files and file_count + if 'files' in d: + if isinstance(d['files'], list): + entry['files'] = d['files'] + elif isinstance(d['files'], str) and d['files']: + entry['files'] = [d['files']] + entry['file_count'] = len(entry['files']) + elif 'file_count' in d: + entry['file_count'] = d['file_count'] + + processed_dupes.append(entry) + + new_dupes = [] + + has_repack_in_uuid = "repack" in meta.get('uuid', '').lower() + video_encode = meta.get("video_encode") + if video_encode is not None: + has_encoder_in_name = video_encode.lower() + normalized_encoder = await normalize_filename(has_encoder_in_name) + else: + normalized_encoder = False + if not meta['is_disc'] == "BDMV": + tracks = meta.get('mediainfo').get('media', {}).get('track', []) + fileSize = tracks[0].get('FileSize', '') + has_is_disc = bool(meta.get('is_disc', False)) + target_hdr = await refine_hdr_terms(meta.get("hdr")) + target_season = meta.get("season") + target_episode = meta.get("episode") + target_resolution = meta.get("resolution") + tag = meta.get("tag").lower().replace("-", " ") + is_dvd = meta['is_disc'] == "DVD" + is_dvdrip = meta['type'] == "DVDRIP" + web_dl = meta.get('type') == "WEBDL" + is_hdtv = meta.get('type') == "HDTV" + target_source = meta.get("source") + is_sd = meta.get('sd') + if not meta['is_disc']: + filenames = [] + if meta.get('filelist'): + for file_path in meta.get('filelist', []): + # Extract just the filename without the path + filename = os.path.basename(file_path) + filenames.append(filename) + + attribute_checks = [ + { + "key": "repack", + "uuid_flag": has_repack_in_uuid, + "condition": lambda each: meta['tag'].lower() in each and has_repack_in_uuid and "repack" not in each.lower(), + "exclude_msg": lambda each: f"Excluding result because it lacks 'repack' and matches tag '{meta['tag']}': {each}" + }, + { + "key": "remux", + "uuid_flag": "remux" in meta.get('name', '').lower(), + "condition": lambda each: "remux" in each.lower(), + "exclude_msg": lambda each: f"Excluding result due to 'remux' mismatch: {each}" + }, + { + "key": "uhd", + "uuid_flag": "uhd" in meta.get('name', '').lower(), + "condition": lambda each: "uhd" in each.lower(), + "exclude_msg": lambda each: f"Excluding result due to 'UHD' mismatch: {each}" + }, + ] + + async def log_exclusion(reason, item): + if meta['debug']: + console.log(f"[yellow]Excluding result due to {reason}: {item}") + + async def process_exclusion(entry): + """ + Determine if an entry should be excluded. + Returns True if the entry should be excluded, otherwise allowed as dupe. + """ + each = entry.get('name', '') + sized = entry.get('size') # This may come as a string, such as "1.5 GB" + files = entry.get('files', []) + file_count = entry.get('file_count', 0) + normalized = await normalize_filename(each) + file_hdr = await refine_hdr_terms(normalized) + + if meta['debug']: + console.log(f"[debug] Evaluating dupe: {each}") + console.log(f"[debug] Normalized dupe: {normalized}") + console.log(f"[debug] Target resolution: {target_resolution}") + console.log(f"[debug] Target source: {target_source}") + console.log(f"[debug] File HDR terms: {file_hdr}") + console.log(f"[debug] Target HDR terms: {target_hdr}") + console.log(f"[debug] Target Season: {target_season}") + console.log(f"[debug] Target Episode: {target_episode}") + console.log(f"[debug] TAG: {tag}") + console.log("[debug] Evaluating repack condition:") + console.log(f" has_repack_in_uuid: {has_repack_in_uuid}") + console.log(f" 'repack' in each.lower(): {'repack' in each.lower()}") + console.log(f"[debug] meta['uuid']: {meta.get('uuid', '')}") + console.log(f"[debug] normalized encoder: {normalized_encoder}") + console.log(f"[debug] link: {entry.get('link', None)}") + console.log(f"[debug] files: {files}") + console.log(f"[debug] file_count: {file_count}") + + if not meta.get('is_disc'): + for file in filenames: + if tracker_name in ["MTV", "AR", "RTF"]: + # MTV: check if any dupe file is a substring of our file (ignoring extension) + if any(f in file for f in files): + meta['filename_match'] = f"{entry.get('name')} = {entry.get('link', None)}" + if file_count and file_count > 0 and file_count == len(meta.get('filelist', [])): + meta['file_count_match'] = file_count + return False + else: + if file in files: + meta['filename_match'] = f"{entry.get('name')} = {entry.get('link', None)}" + if file_count and file_count > 0 and file_count == len(meta.get('filelist', [])): + meta['file_count_match'] = file_count + return False + + if tracker_name == "MTV": + target_name = meta.get('name').replace(' ', '.').replace('DD+', 'DDP') + dupe_name = str(entry.get('name')) + + def normalize_mtv_name(name): + # Handle audio format variations: DDP.5.1 <-> DDP5.1 + name = re.sub(r'\.DDP\.(\d)', r'.DDP\1', name) + name = re.sub(r'\.DD\.(\d)', r'.DD\1', name) + name = re.sub(r'\.AC3\.(\d)', r'.AC3\1', name) + name = re.sub(r'\.DTS\.(\d)', r'.DTS\1', name) + return name + normalized_target = normalize_mtv_name(target_name) + if normalized_target == dupe_name: + meta['filename_match'] = f"{entry.get('name')} = {entry.get('link', None)}" + return False + + if tracker_name == "BHD": + target_name = meta.get('name').replace('DD+', 'DDP') + if str(entry.get('name')) == target_name: + meta['filename_match'] = f"{entry.get('name')} = {entry.get('link', None)}" + return False + + if tracker_name == "HUNO": + huno = HUNO(config=config) + huno_name_result = await huno.get_name(meta) + if isinstance(huno_name_result, dict) and 'name' in huno_name_result: + huno_name = huno_name_result['name'] + else: + huno_name = str(huno_name_result) + if str(entry.get('name')) == huno_name: + meta['filename_match'] = f"{entry.get('name')} = {entry.get('link', None)}" + return False + + if tracker_name == "AITHER" and entry.get('trumpable', False): + meta['trumpable'] = entry.get('link', None) + + if has_is_disc and each.lower().endswith(".m2ts"): + return False + + if has_is_disc and re.search(r'\.\w{2,4}$', each): + await log_exclusion("file extension mismatch (is_disc=True)", each) + return True + + if meta.get('is_disc') == "BDMV" and tracker_name in ["AITHER", "LST", "HDB", "BHD"]: + if len(each) >= 1 and tag == "": + return False + if tag and tag.strip() and tag.strip() in normalized: + return False + return True + + if is_sd == 1 and (tracker_name == "BHD" or tracker_name == "AITHER"): + if any(str(res) in each for res in [1080, 720, 2160]): + return False + + if target_hdr and '1080p' in target_resolution and '2160p' in each: + await log_exclusion("No 1080p HDR when 4K exists", each) + return False + + if tracker_name in ["AITHER", "LST"] and is_dvd: + if len(each) >= 1 and tag == "": + return False + if tag and tag.strip() and tag.strip() in normalized: + return False + return True + + if web_dl: + if "hdtv" in normalized and not any(web_term in normalized for web_term in ["web-dl", "webdl", "web dl"]): + await log_exclusion("source mismatch: WEB-DL vs HDTV", each) + return True + + if is_dvd or "DVD" in target_source or is_dvdrip: + skip_resolution_check = True + else: + skip_resolution_check = False + + if not skip_resolution_check: + if target_resolution and target_resolution not in each: + await log_exclusion(f"resolution '{target_resolution}' mismatch", each) + return True + if not await has_matching_hdr(file_hdr, target_hdr, meta, tracker=tracker_name): + await log_exclusion(f"HDR mismatch: Expected {target_hdr}, got {file_hdr}", each) + return True + + if is_dvd and not tracker_name == "BHD": + if any(str(res) in each for res in [1080, 720, 2160]): + await log_exclusion(f"resolution '{target_resolution}' mismatch", each) + return False + + for check in attribute_checks: + if check["key"] == "repack": + if has_repack_in_uuid and "repack" not in normalized: + if tag and tag in normalized: + await log_exclusion("missing 'repack'", each) + return True + elif check["uuid_flag"] != check["condition"](each): + await log_exclusion(f"{check['key']} mismatch", each) + return True + + if meta.get('category') == "TV": + season_episode_match = await is_season_episode_match(normalized, target_season, target_episode) + if meta['debug']: + console.log(f"[debug] Season/Episode match result: {season_episode_match}") + if not season_episode_match: + await log_exclusion("season/episode mismatch", each) + return True + + if is_hdtv: + if any(web_term in normalized for web_term in ["web-dl", "webdl", "web dl"]): + return False + + if len(dupes) == 1 and meta.get('is_disc') != "BDMV": + if tracker_name in ["AITHER", "BHD", "HUNO", "OE", "ULCX"]: + if fileSize and "1080" in target_resolution and 'x264' in video_encode: + target_size = int(fileSize) + dupe_size = sized + + if dupe_size is not None and target_size is not None: + size_difference = (target_size - dupe_size) / dupe_size + if meta['debug']: + console.print(f"Your size: {target_size}, Dupe size: {dupe_size}, Size difference: {size_difference:.4f}") + if size_difference >= 0.20: + await log_exclusion(f"Your file is significantly larger ({size_difference * 100:.2f}%)", each) + return True + if tracker_name == "RF": + if tag and tag.strip() and tag.strip() in normalized: + return False + elif tag and tag.strip() and tag.strip() not in normalized: + await log_exclusion(f"Tag '{tag}' not found in normalized name", each) + return True + + if meta['debug']: + console.log(f"[debug] Passed all checks: {each}") + return False + + for each in processed_dupes: + if not await process_exclusion(each): + new_dupes.append(each) + + if new_dupes and not meta.get('unattended', False) and meta['debug']: + console.log(f"[yellow]Filtered dupes on {tracker_name}: {new_dupes}") + + return new_dupes + + +async def normalize_filename(filename): + if isinstance(filename, dict): + filename = filename.get('name', '') + if not isinstance(filename, str): + raise ValueError(f"Expected a string or a dictionary with a 'name' key, but got: {type(filename)}") + normalized = filename.lower().replace("-", " -").replace(" ", " ").replace(".", " ") + + return normalized + + +async def is_season_episode_match(filename, target_season, target_episode): + """ + Check if the filename matches the given season and episode. + """ + season_match = re.search(r'[sS](\d+)', str(target_season)) + target_season = int(season_match.group(1)) if season_match else None + + if target_episode: + episode_matches = re.findall(r'\d+', str(target_episode)) + target_episodes = [int(ep) for ep in episode_matches] + else: + target_episodes = [] + + season_pattern = rf"[sS]{target_season:02}" if target_season is not None else None + episode_patterns = [rf"[eE]{ep:02}" for ep in target_episodes] if target_episodes else [] + + # Determine if filename represents a season pack (no explicit episode pattern) + is_season_pack = not re.search(r"[eE]\d{2}", filename, re.IGNORECASE) + + # If `target_episode` is empty, match only season packs + if not target_episodes: + return bool(season_pattern and re.search(season_pattern, filename, re.IGNORECASE)) and is_season_pack + + # If `target_episode` is provided, match both season packs and episode files + if season_pattern: + if is_season_pack: + return bool(re.search(season_pattern, filename, re.IGNORECASE)) # Match season pack + if episode_patterns: + return bool(re.search(season_pattern, filename, re.IGNORECASE)) and any( + re.search(ep, filename, re.IGNORECASE) for ep in episode_patterns + ) # Match episode file + + return False # No match + + +async def refine_hdr_terms(hdr): + """ + Normalize HDR terms for consistent comparison. + Simplifies all HDR entries to 'HDR' and DV entries to 'DV'. + """ + if hdr is None: + return set() + hdr = hdr.upper() + terms = set() + if "DV" in hdr or "DOVI" in hdr: + terms.add("DV") + if "HDR" in hdr: # Any HDR-related term is normalized to 'HDR' + terms.add("HDR") + return terms + + +async def has_matching_hdr(file_hdr, target_hdr, meta, tracker=None): + """ + Check if the HDR terms match or are compatible. + """ + def simplify_hdr(hdr_set, tracker=None): + """Simplify HDR terms to just HDR and DV.""" + simplified = set() + if any(h in hdr_set for h in {"HDR", "HDR10", "HDR10+"}): + simplified.add("HDR") + if ".DV." in hdr_set or " DV " in hdr_set or "DOVI" in hdr_set: + simplified.add("DV") + if 'web' not in meta['type'].lower(): + simplified.add("HDR") + if tracker == "ANT": + simplified.add("HDR") + return simplified + + file_hdr_simple = simplify_hdr(file_hdr, tracker) + target_hdr_simple = simplify_hdr(target_hdr, tracker) + + if file_hdr_simple == {"DV", "HDR"} or file_hdr_simple == {"HDR", "DV"}: + file_hdr_simple = {"HDR"} + if target_hdr_simple == {"DV", "HDR"} or target_hdr_simple == {"HDR", "DV"}: + target_hdr_simple = {"HDR"} + + return file_hdr_simple == target_hdr_simple diff --git a/src/edition.py b/src/edition.py new file mode 100644 index 000000000..401c24a20 --- /dev/null +++ b/src/edition.py @@ -0,0 +1,360 @@ +from guessit import guessit +import os +import re +from src.console import console + + +async def get_edition(video, bdinfo, filelist, manual_edition, meta): + edition = "" + + if meta.get('category') == "MOVIE" and not meta.get('anime'): + if meta.get('imdb_info', {}).get('edition_details') and not manual_edition: + if not meta.get('is_disc') == "BDMV" and meta.get('mediainfo', {}).get('media', {}).get('track'): + general_track = next((track for track in meta['mediainfo']['media']['track'] + if track.get('@type') == 'General'), None) + + if general_track and general_track.get('Duration'): + try: + media_duration_seconds = float(general_track['Duration']) + formatted_duration = format_duration(media_duration_seconds) + if meta['debug']: + console.print(f"[cyan]Found media duration: {formatted_duration} ({media_duration_seconds} seconds)[/cyan]") + + leeway_seconds = 50 + matching_editions = [] + + # Find all matching editions + for runtime_key, edition_info in meta['imdb_info']['edition_details'].items(): + edition_seconds = edition_info.get('seconds', 0) + edition_formatted = format_duration(edition_seconds) + difference = abs(media_duration_seconds - edition_seconds) + + if difference <= leeway_seconds: + has_attributes = bool(edition_info.get('attributes') and len(edition_info['attributes']) > 0) + if meta['debug']: + console.print(f"[green]Potential match: {edition_info['display_name']} - duration {edition_formatted}, difference: {format_duration(difference)}[/green]") + + if has_attributes: + edition_name = " ".join(smart_title(attr) for attr in edition_info['attributes']) + + matching_editions.append({ + 'name': edition_name, + 'display_name': edition_info['display_name'], + 'has_attributes': bool(edition_info.get('attributes') and len(edition_info['attributes']) > 0), + 'minutes': edition_info['minutes'], + 'difference': difference, + 'formatted_duration': edition_formatted + }) + else: + if meta['debug']: + console.print("[yellow]Edition without attributes are theatrical editions and skipped[/yellow]") + + if len(matching_editions) > 1: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print(f"[yellow]Media file duration {formatted_duration} matches multiple editions:[/yellow]") + for i, ed in enumerate(matching_editions): + diff_formatted = format_duration(ed['difference']) + console.print(f"[yellow]{i+1}. [green]{ed['name']} ({ed['display_name']}, duration: {ed['formatted_duration']}, diff: {diff_formatted})[/yellow]") + + try: + choice = console.input(f"[yellow]Select edition number (1-{len(matching_editions)}) or press Enter to use the closest match: [/yellow]") + + if choice.strip() and choice.isdigit() and 1 <= int(choice) <= len(matching_editions): + selected = matching_editions[int(choice)-1] + else: + selected = min(matching_editions, key=lambda x: x['difference']) + console.print(f"[yellow]Using closest match: {selected['name']}[/yellow]") + except Exception as e: + console.print(f"[red]Error processing selection: {e}. Using closest match.[/red]") + selected = min(matching_editions, key=lambda x: x['difference']) + else: + selected = min(matching_editions, key=lambda x: x['difference']) + console.print(f"[yellow]Multiple matches found in unattended mode. Using closest match: {selected['name']}[/yellow]") + + if selected['has_attributes']: + edition = selected['name'] + else: + edition = "" + + console.print(f"[bold green]Setting edition from duration match: {edition}[/bold green]") + + elif len(matching_editions) == 1: + selected = matching_editions[0] + if selected['has_attributes']: + edition = selected['name'] + else: + edition = "" # No special edition for single matches without attributes + + console.print(f"[bold green]Setting edition from duration match: {edition}[/bold green]") + + else: + if meta['debug']: + console.print(f"[yellow]No matching editions found within {leeway_seconds} seconds of media duration[/yellow]") + + except (ValueError, TypeError) as e: + console.print(f"[yellow]Error parsing duration: {e}[/yellow]") + + elif meta.get('is_disc') == "BDMV" and meta.get('discs'): + if meta['debug']: + console.print("[cyan]Checking BDMV playlists for edition matches...[/cyan]") + matched_editions = [] + + all_playlists = [] + for disc in meta['discs']: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + if disc.get('playlists'): + all_playlists.extend(disc['playlists']) + else: + if disc.get('all_valid_playlists'): + all_playlists.extend(disc['all_valid_playlists']) + if meta['debug']: + console.print(f"[cyan]Found {len(all_playlists)} playlists to check against IMDb editions[/cyan]") + + leeway_seconds = 50 + matched_editions_with_attributes = [] + matched_editions_without_attributes = [] + + for playlist in all_playlists: + if playlist.get('file', None): + playlist_file = playlist['file'] + else: + playlist_file = "" + if playlist.get('edition'): + playlist_edition = playlist['edition'] + else: + playlist_edition = "" + if playlist.get('duration'): + playlist_duration = float(playlist['duration']) + formatted_duration = format_duration(playlist_duration) + if meta['debug']: + console.print(f"[cyan]Checking playlist duration: {formatted_duration} seconds[/cyan]") + + matching_editions = [] + + for runtime_key, edition_info in meta['imdb_info']['edition_details'].items(): + edition_seconds = edition_info.get('seconds', 0) + difference = abs(playlist_duration - edition_seconds) + + if difference <= leeway_seconds: + # Store the complete edition info + if edition_info.get('attributes') and len(edition_info['attributes']) > 0: + edition_name = " ".join(smart_title(attr) for attr in edition_info['attributes']) + else: + edition_name = f"{edition_info['minutes']} Minute Version (Theatrical)" + + matching_editions.append({ + 'name': edition_name, + 'display_name': edition_info['display_name'], + 'has_attributes': bool(edition_info.get('attributes') and len(edition_info['attributes']) > 0), + 'minutes': edition_info['minutes'], + 'difference': difference + }) + + # If multiple editions match this playlist, ask the user + if len(matching_editions) > 1: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print(f"[yellow]Playlist edition [green]{playlist_edition} [yellow]using file [green]{playlist_file} [yellow]with duration [green]{formatted_duration} [yellow]matches multiple editions:[/yellow]") + for i, ed in enumerate(matching_editions): + console.print(f"[yellow]{i+1}. [green]{ed['name']} ({ed['display_name']}, diff: {ed['difference']:.2f} seconds)") + + try: + choice = console.input(f"[yellow]Select edition number (1-{len(matching_editions)}), press e to use playlist edition or press Enter to use the closest match: [/yellow]") + + if choice.strip() and choice.isdigit() and 1 <= int(choice) <= len(matching_editions): + selected = matching_editions[int(choice)-1] + elif choice.strip().lower() == 'e': + selected = playlist_edition + else: + # Default to the closest match (smallest difference) + selected = min(matching_editions, key=lambda x: x['difference']) + console.print(f"[yellow]Using closest match: {selected['name']}[/yellow]") + + # Add the selected edition to our matches + if selected == playlist_edition: + console.print(f"[green]Using playlist edition: {selected}[/green]") + matched_editions_with_attributes.append(selected) + elif selected['has_attributes']: + if selected['name'] not in matched_editions_with_attributes: + matched_editions_with_attributes.append(selected['name']) + console.print(f"[green]Added edition with attributes: {selected['name']}[/green]") + else: + matched_editions_without_attributes.append(str(selected['minutes'])) + console.print(f"[yellow]Added edition without attributes: {selected['name']}[/yellow]") + + except Exception as e: + console.print(f"[red]Error processing selection: {e}. Using closest match.[/red]") + # Default to closest match + selected = min(matching_editions, key=lambda x: x['difference']) + if selected['has_attributes']: + matched_editions_with_attributes.append(selected['name']) + else: + matched_editions_without_attributes.append(str(selected['minutes'])) + else: + console.print(f"[yellow]Playlist edition [green]{playlist_edition} [yellow]using file [green]{playlist_file} [yellow]with duration [green]{formatted_duration} [yellow]matches multiple editions, but unattended mode is enabled. Using closest match.[/yellow]") + selected = min(matching_editions, key=lambda x: x['difference']) + if selected['has_attributes']: + matched_editions_with_attributes.append(selected['name']) + else: + matched_editions_without_attributes.append(str(selected['minutes'])) + + # If just one edition matches, add it directly + elif len(matching_editions) == 1: + edition_info = matching_editions[0] + console.print(f"[green]Playlist {playlist_edition} matches edition: {edition_info['display_name']} {edition_name}[/green]") + + if edition_info['has_attributes']: + if edition_info['name'] not in matched_editions_with_attributes: + matched_editions_with_attributes.append(edition_info['name']) + if meta['debug']: + console.print(f"[green]Added edition with attributes: {edition_info['name']}[/green]") + else: + matched_editions_without_attributes.append(str(edition_info['minutes'])) + if meta['debug']: + console.print(f"[yellow]Added edition without attributes: {edition_info['name']}[/yellow]") + + # Process the matched editions + if matched_editions_with_attributes or matched_editions_without_attributes: + # Only use "Theatrical" if we have at least one edition with attributes + if matched_editions_with_attributes and matched_editions_without_attributes: + matched_editions = matched_editions_with_attributes + ["Theatrical"] + if meta['debug']: + console.print("[cyan]Adding 'Theatrical' label because we have both attribute and non-attribute editions[/cyan]") + else: + matched_editions = matched_editions_with_attributes + if meta['debug']: + console.print("[cyan]Using only editions with attributes[/cyan]") + + # Handle final edition formatting + if matched_editions: + # If multiple editions, prefix with count + if len(matched_editions) > 1: + unique_editions = list(set(matched_editions)) # Remove duplicates + if "Theatrical" in unique_editions: + unique_editions.remove("Theatrical") + unique_editions = ["Theatrical"] + sorted(unique_editions) + if len(unique_editions) > 1: + edition = f"{len(unique_editions)}in1 " + " / ".join(unique_editions) + else: + edition = unique_editions[0] # Just one unique edition + else: + edition = matched_editions[0] + + if meta['debug']: + console.print(f"[bold green]Setting edition from BDMV playlist matches: {edition}[/bold green]") + + if edition and (edition.lower() in ["cut", "approximate"] or len(edition) < 6): + edition = "" + if edition and "edition" in edition.lower(): + edition = re.sub(r'\bedition\b', '', edition, flags=re.IGNORECASE).strip() + if edition and "extended" in edition.lower(): + edition = "Extended" + + if not edition: + if video.lower().startswith('dc'): + video = video.lower().replace('dc', '', 1) + + guess = guessit(video) + tag = guess.get('release_group', 'NOGROUP') + if isinstance(tag, list): + tag = " ".join(str(t) for t in tag) + repack = "" + + if bdinfo is not None: + try: + edition = guessit(bdinfo['label'])['edition'] + except Exception as e: + if meta['debug']: + print(f"BDInfo Edition Guess Error: {e}") + edition = "" + else: + try: + edition = guess.get('edition', "") + except Exception as e: + if meta['debug']: + print(f"Video Edition Guess Error: {e}") + edition = "" + + if isinstance(edition, list): + edition = " ".join(str(e) for e in edition) + + if len(filelist) == 1: + video = os.path.basename(video) + + video = video.upper().replace('.', ' ').replace(tag.upper(), '').replace('-', '') + + if "OPEN MATTE" in video.upper(): + edition = edition + " Open Matte" + + # Manual edition overrides everything + if manual_edition: + if isinstance(manual_edition, list): + manual_edition = " ".join(str(e) for e in manual_edition) + edition = str(manual_edition) + + edition = edition.replace(",", " ") + + # Handle repack info + repack = "" + if "REPACK" in (video.upper() or edition.upper()) or "V2" in video: + repack = "REPACK" + if "REPACK2" in (video.upper() or edition.upper()) or "V3" in video: + repack = "REPACK2" + if "REPACK3" in (video.upper() or edition.upper()) or "V4" in video: + repack = "REPACK3" + if "PROPER" in (video.upper() or edition.upper()): + repack = "PROPER" + if "PROPER2" in (video.upper() or edition.upper()): + repack = "PROPER2" + if "PROPER3" in (video.upper() or edition.upper()): + repack = "PROPER3" + if "RERIP" in (video.upper() or edition.upper()): + repack = "RERIP" + + # Only remove REPACK, RERIP, or PROPER from edition if not in manual edition + if not manual_edition or all(tag.lower() not in ['repack', 'repack2', 'repack3', 'proper', 'proper2', 'proper3', 'rerip'] for tag in manual_edition.strip().lower().split()): + edition = re.sub(r"(\bREPACK\d?\b|\bRERIP\b|\bPROPER\b)", "", edition, flags=re.IGNORECASE).strip() + + if not meta.get('webdv', False): + hybrid = False + if "HYBRID" in video.upper() or "HYBRID" in edition.upper(): + hybrid = True + else: + hybrid = meta.get('webdv', False) + + # Handle distributor info + if edition: + from src.region import get_distributor + distributors = await get_distributor(edition) + + bad = ['internal', 'limited', 'retail', 'version', 'remastered'] + + if distributors: + bad.append(distributors.lower()) + meta['distributor'] = distributors + + if any(term.lower() in edition.lower() for term in bad): + edition = re.sub(r'\b(?:' + '|'.join(bad) + r')\b', '', edition, flags=re.IGNORECASE).strip() + # Clean up extra spaces + while ' ' in edition: + edition = edition.replace(' ', ' ') + + if edition != "": + if meta['debug']: + console.print(f"Final Edition: {edition}") + + return edition, repack, hybrid + + +def format_duration(seconds): + """Convert seconds to a human-readable HH:MM:SS format.""" + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = int(seconds % 60) + return f"{hours}:{minutes:02d}:{secs:02d}" + + +def smart_title(s): + """Custom title function that doesn't capitalize after apostrophes""" + result = s.title() + # Fix capitalization after apostrophes + return re.sub(r"(\w)'(\w)", lambda m: f"{m.group(1)}'{m.group(2).lower()}", result) diff --git a/src/exceptions.py b/src/exceptions.py index b4c6dbead..e5de6f944 100644 --- a/src/exceptions.py +++ b/src/exceptions.py @@ -7,9 +7,10 @@ def __init__(self, *args, **kwargs): if args: # ... pass them to the super constructor super().__init__(*args, **kwargs) - else: # else, the exception was raised without arguments ... - # ... pass the default message to the super constructor - super().__init__(default_message, **kwargs) + else: # else, the exception was raised without arguments ... + # ... pass the default message to the super constructor + super().__init__(default_message, **kwargs) + class UploadException(Exception): def __init__(self, *args, **kwargs): @@ -20,14 +21,18 @@ def __init__(self, *args, **kwargs): if args: # ... pass them to the super constructor super().__init__(*args, **kwargs) - else: # else, the exception was raised without arguments ... - # ... pass the default message to the super constructor - super().__init__(default_message, **kwargs) + else: # else, the exception was raised without arguments ... + # ... pass the default message to the super constructor + super().__init__(default_message, **kwargs) class XEMNotFound(Exception): pass + + class WeirdSystem(Exception): pass + + class ManualDateException(Exception): - pass \ No newline at end of file + pass diff --git a/src/exportmi.py b/src/exportmi.py new file mode 100644 index 000000000..5207705f5 --- /dev/null +++ b/src/exportmi.py @@ -0,0 +1,389 @@ +from src.console import console +from pymediainfo import MediaInfo +import json +import os +import platform + + +async def mi_resolution(res, guess, width, scan, height, actual_height): + res_map = { + "3840x2160p": "2160p", "2160p": "2160p", + "2560x1440p": "1440p", "1440p": "1440p", + "1920x1080p": "1080p", "1080p": "1080p", + "1920x1080i": "1080i", "1080i": "1080i", + "1280x720p": "720p", "720p": "720p", + "1280x540p": "720p", "1280x576p": "720p", + "1024x576p": "576p", "576p": "576p", + "1024x576i": "576i", "576i": "576i", + "960x540p": "540p", "540p": "540p", + "960x540i": "540i", "540i": "540i", + "854x480p": "480p", "480p": "480p", + "854x480i": "480i", "480i": "480i", + "720x576p": "576p", "576p": "576p", + "720x576i": "576i", "576i": "576i", + "720x480p": "480p", "480p": "480p", + "720x480i": "480i", "480i": "480i", + "15360x8640p": "8640p", "8640p": "8640p", + "7680x4320p": "4320p", "4320p": "4320p", + "OTHER": "OTHER"} + resolution = res_map.get(res, None) + if resolution is None: + try: + resolution = guess['screen_size'] + # Check if the resolution from guess exists in our map + if resolution not in res_map: + # If not in the map, use width-based mapping + width_map = { + '3840p': '2160p', + '2560p': '1550p', + '1920p': '1080p', + '1920i': '1080i', + '1280p': '720p', + '1024p': '576p', + '1024i': '576i', + '960p': '540p', + '960i': '540i', + '854p': '480p', + '854i': '480i', + '720p': '576p', + '720i': '576i', + '15360p': '4320p', + 'OTHERp': 'OTHER' + } + resolution = width_map.get(f"{width}{scan}", "OTHER") + except Exception: + # If we can't get from guess, use width-based mapping + width_map = { + '3840p': '2160p', + '2560p': '1550p', + '1920p': '1080p', + '1920i': '1080i', + '1280p': '720p', + '1024p': '576p', + '1024i': '576i', + '960p': '540p', + '960i': '540i', + '854p': '480p', + '854i': '480i', + '720p': '576p', + '720i': '576i', + '15360p': '4320p', + 'OTHERp': 'OTHER' + } + resolution = width_map.get(f"{width}{scan}", "OTHER") + + # Final check to ensure we have a valid resolution + if resolution not in res_map: + resolution = "OTHER" + + return resolution + + +async def exportInfo(video, isdir, folder_id, base_dir, export_text, is_dvd=False, debug=False): + def filter_mediainfo(data): + filtered = { + "creatingLibrary": data.get("creatingLibrary"), + "media": { + "@ref": data["media"]["@ref"], + "track": [] + } + } + + for track in data["media"]["track"]: + if track["@type"] == "General": + filtered["media"]["track"].append({ + "@type": track["@type"], + "UniqueID": track.get("UniqueID", {}), + "VideoCount": track.get("VideoCount", {}), + "AudioCount": track.get("AudioCount", {}), + "TextCount": track.get("TextCount", {}), + "MenuCount": track.get("MenuCount", {}), + "FileExtension": track.get("FileExtension", {}), + "Format": track.get("Format", {}), + "Format_Version": track.get("Format_Version", {}), + "FileSize": track.get("FileSize", {}), + "Duration": track.get("Duration", {}), + "OverallBitRate": track.get("OverallBitRate", {}), + "FrameRate": track.get("FrameRate", {}), + "FrameCount": track.get("FrameCount", {}), + "StreamSize": track.get("StreamSize", {}), + "IsStreamable": track.get("IsStreamable", {}), + "File_Created_Date": track.get("File_Created_Date", {}), + "File_Created_Date_Local": track.get("File_Created_Date_Local", {}), + "File_Modified_Date": track.get("File_Modified_Date", {}), + "File_Modified_Date_Local": track.get("File_Modified_Date_Local", {}), + "Encoded_Application": track.get("Encoded_Application", {}), + "Encoded_Library": track.get("Encoded_Library", {}), + "extra": track.get("extra", {}), + }) + elif track["@type"] == "Video": + filtered["media"]["track"].append({ + "@type": track["@type"], + "StreamOrder": track.get("StreamOrder", {}), + "ID": track.get("ID", {}), + "UniqueID": track.get("UniqueID", {}), + "Format": track.get("Format", {}), + "Format_Profile": track.get("Format_Profile", {}), + "Format_Version": track.get("Format_Version", {}), + "Format_Level": track.get("Format_Level", {}), + "Format_Tier": track.get("Format_Tier", {}), + "HDR_Format": track.get("HDR_Format", {}), + "HDR_Format_Version": track.get("HDR_Format_Version", {}), + "HDR_Format_String": track.get("HDR_Format_String", {}), + "HDR_Format_Profile": track.get("HDR_Format_Profile", {}), + "HDR_Format_Level": track.get("HDR_Format_Level", {}), + "HDR_Format_Settings": track.get("HDR_Format_Settings", {}), + "HDR_Format_Compression": track.get("HDR_Format_Compression", {}), + "HDR_Format_Compatibility": track.get("HDR_Format_Compatibility", {}), + "CodecID": track.get("CodecID", {}), + "CodecID_Hint": track.get("CodecID_Hint", {}), + "Duration": track.get("Duration", {}), + "BitRate": track.get("BitRate", {}), + "Width": track.get("Width", {}), + "Height": track.get("Height", {}), + "Stored_Height": track.get("Stored_Height", {}), + "Sampled_Width": track.get("Sampled_Width", {}), + "Sampled_Height": track.get("Sampled_Height", {}), + "PixelAspectRatio": track.get("PixelAspectRatio", {}), + "DisplayAspectRatio": track.get("DisplayAspectRatio", {}), + "FrameRate_Mode": track.get("FrameRate_Mode", {}), + "FrameRate": track.get("FrameRate", {}), + "FrameRate_Original": track.get("FrameRate_Original", {}), + "FrameRate_Num": track.get("FrameRate_Num", {}), + "FrameRate_Den": track.get("FrameRate_Den", {}), + "FrameCount": track.get("FrameCount", {}), + "Standard": track.get("Standard", {}), + "ColorSpace": track.get("ColorSpace", {}), + "ChromaSubsampling": track.get("ChromaSubsampling", {}), + "ChromaSubsampling_Position": track.get("ChromaSubsampling_Position", {}), + "BitDepth": track.get("BitDepth", {}), + "ScanType": track.get("ScanType", {}), + "ScanOrder": track.get("ScanOrder", {}), + "Delay": track.get("Delay", {}), + "Delay_Source": track.get("Delay_Source", {}), + "StreamSize": track.get("StreamSize", {}), + "Language": track.get("Language", {}), + "Default": track.get("Default", {}), + "Forced": track.get("Forced", {}), + "colour_description_present": track.get("colour_description_present", {}), + "colour_description_present_Source": track.get("colour_description_present_Source", {}), + "colour_range": track.get("colour_range", {}), + "colour_range_Source": track.get("colour_range_Source", {}), + "colour_primaries": track.get("colour_primaries", {}), + "colour_primaries_Source": track.get("colour_primaries_Source", {}), + "transfer_characteristics": track.get("transfer_characteristics", {}), + "transfer_characteristics_Source": track.get("transfer_characteristics_Source", {}), + "transfer_characteristics_Original": track.get("transfer_characteristics_Original", {}), + "matrix_coefficients": track.get("matrix_coefficients", {}), + "matrix_coefficients_Source": track.get("matrix_coefficients_Source", {}), + "MasteringDisplay_ColorPrimaries": track.get("MasteringDisplay_ColorPrimaries", {}), + "MasteringDisplay_ColorPrimaries_Source": track.get("MasteringDisplay_ColorPrimaries_Source", {}), + "MasteringDisplay_Luminance": track.get("MasteringDisplay_Luminance", {}), + "MasteringDisplay_Luminance_Source": track.get("MasteringDisplay_Luminance_Source", {}), + "MaxCLL": track.get("MaxCLL", {}), + "MaxCLL_Source": track.get("MaxCLL_Source", {}), + "MaxFALL": track.get("MaxFALL", {}), + "MaxFALL_Source": track.get("MaxFALL_Source", {}), + "Encoded_Library_Settings": track.get("Encoded_Library_Settings", {}), + "Encoded_Library": track.get("Encoded_Library", {}), + "Encoded_Library_Name": track.get("Encoded_Library_Name", {}), + }) + elif track["@type"] == "Audio": + filtered["media"]["track"].append({ + "@type": track["@type"], + "StreamOrder": track.get("StreamOrder", {}), + "ID": track.get("ID", {}), + "UniqueID": track.get("UniqueID", {}), + "Format": track.get("Format", {}), + "Format_Version": track.get("Format_Version", {}), + "Format_Profile": track.get("Format_Profile", {}), + "Format_Settings": track.get("Format_Settings", {}), + "Format_Commercial_IfAny": track.get("Format_Commercial_IfAny", {}), + "Format_Settings_Endianness": track.get("Format_Settings_Endianness", {}), + "Format_AdditionalFeatures": track.get("Format_AdditionalFeatures", {}), + "CodecID": track.get("CodecID", {}), + "Duration": track.get("Duration", {}), + "BitRate_Mode": track.get("BitRate_Mode", {}), + "BitRate": track.get("BitRate", {}), + "Channels": track.get("Channels", {}), + "ChannelPositions": track.get("ChannelPositions", {}), + "ChannelLayout": track.get("ChannelLayout", {}), + "Channels_Original": track.get("Channels_Original", {}), + "ChannelLayout_Original": track.get("ChannelLayout_Original", {}), + "SamplesPerFrame": track.get("SamplesPerFrame", {}), + "SamplingRate": track.get("SamplingRate", {}), + "SamplingCount": track.get("SamplingCount", {}), + "FrameRate": track.get("FrameRate", {}), + "FrameCount": track.get("FrameCount", {}), + "Compression_Mode": track.get("Compression_Mode", {}), + "Delay": track.get("Delay", {}), + "Delay_Source": track.get("Delay_Source", {}), + "Video_Delay": track.get("Video_Delay", {}), + "StreamSize": track.get("StreamSize", {}), + "Title": track.get("Title", {}), + "Language": track.get("Language", {}), + "ServiceKind": track.get("ServiceKind", {}), + "Default": track.get("Default", {}), + "Forced": track.get("Forced", {}), + "extra": track.get("extra", {}), + }) + elif track["@type"] == "Text": + filtered["media"]["track"].append({ + "@type": track["@type"], + "@typeorder": track.get("@typeorder", {}), + "StreamOrder": track.get("StreamOrder", {}), + "ID": track.get("ID", {}), + "UniqueID": track.get("UniqueID", {}), + "Format": track.get("Format", {}), + "CodecID": track.get("CodecID", {}), + "Duration": track.get("Duration", {}), + "BitRate": track.get("BitRate", {}), + "FrameRate": track.get("FrameRate", {}), + "FrameCount": track.get("FrameCount", {}), + "ElementCount": track.get("ElementCount", {}), + "StreamSize": track.get("StreamSize", {}), + "Title": track.get("Title", {}), + "Language": track.get("Language", {}), + "Default": track.get("Default", {}), + "Forced": track.get("Forced", {}), + }) + elif track["@type"] == "Menu": + filtered["media"]["track"].append({ + "@type": track["@type"], + "extra": track.get("extra", {}), + }) + return filtered + + mediainfo_cmd = None + if is_dvd: + if debug: + console.print("[bold yellow]DVD detected, using specialized MediaInfo binary...") + mediainfo_binary = os.path.join(base_dir, "bin", "MI", "windows", "MediaInfo.exe") + + if platform.system() == "windows" and os.path.exists(mediainfo_binary): + mediainfo_cmd = mediainfo_binary + + if not os.path.exists(f"{base_dir}/tmp/{folder_id}/MEDIAINFO.txt") and export_text: + if debug: + console.print("[bold yellow]Exporting MediaInfo...") + if not isdir: + os.chdir(os.path.dirname(video)) + + if mediainfo_cmd: + import subprocess + try: + # Handle both string and list command formats + if isinstance(mediainfo_cmd, list): + result = subprocess.run(mediainfo_cmd + [video], capture_output=True, text=True) + else: + result = subprocess.run([mediainfo_cmd, video], capture_output=True, text=True) + media_info = result.stdout + except Exception as e: + console.print(f"[bold red]Error using specialized MediaInfo binary: {e}") + console.print("[bold yellow]Falling back to standard MediaInfo...") + media_info = MediaInfo.parse(video, output="STRING", full=False) + else: + media_info = MediaInfo.parse(video, output="STRING", full=False) + + if isinstance(media_info, str): + filtered_media_info = "\n".join( + line for line in media_info.splitlines() + if not line.strip().startswith("ReportBy") and not line.strip().startswith("Report created by ") + ) + else: + filtered_media_info = "\n".join( + line for line in media_info.splitlines() + if not line.strip().startswith("ReportBy") and not line.strip().startswith("Report created by ") + ) + + with open(f"{base_dir}/tmp/{folder_id}/MEDIAINFO.txt", 'w', newline="", encoding='utf-8') as export: + export.write(filtered_media_info.replace(video, os.path.basename(video))) + with open(f"{base_dir}/tmp/{folder_id}/MEDIAINFO_CLEANPATH.txt", 'w', newline="", encoding='utf-8') as export_cleanpath: + export_cleanpath.write(filtered_media_info.replace(video, os.path.basename(video))) + if debug: + console.print("[bold green]MediaInfo Exported.") + + if not os.path.exists(f"{base_dir}/tmp/{folder_id}/MediaInfo.json"): + if mediainfo_cmd: + import subprocess + try: + # Handle both string and list command formats + if isinstance(mediainfo_cmd, list): + result = subprocess.run(mediainfo_cmd + ["--Output=JSON", video], capture_output=True, text=True) + else: + result = subprocess.run([mediainfo_cmd, "--Output=JSON", video], capture_output=True, text=True) + media_info_json = result.stdout + media_info_dict = json.loads(media_info_json) + except Exception as e: + console.print(f"[bold red]Error getting JSON from specialized MediaInfo binary: {e}") + console.print("[bold yellow]Falling back to standard MediaInfo for JSON...") + media_info_json = MediaInfo.parse(video, output="JSON") + media_info_dict = json.loads(media_info_json) + else: + media_info_json = MediaInfo.parse(video, output="JSON") + media_info_dict = json.loads(media_info_json) + + filtered_info = filter_mediainfo(media_info_dict) + with open(f"{base_dir}/tmp/{folder_id}/MediaInfo.json", 'w', encoding='utf-8') as export: + json.dump(filtered_info, export, indent=4) + + with open(f"{base_dir}/tmp/{folder_id}/MediaInfo.json", 'r', encoding='utf-8') as f: + mi = json.load(f) + + return mi + + +def validate_mediainfo(base_dir, folder_id, path, filelist, debug): + if not (path.lower().endswith('.mkv') or any(str(f).lower().endswith('.mkv') for f in filelist)): + if debug: + console.print(f"[yellow]Skipping {path} (not an .mkv file)[/yellow]") + return True + mediainfo_path = f"{base_dir}/tmp/{folder_id}/MEDIAINFO.txt" + unique_id = None + in_general = False + + if debug: + console.print(f"[cyan]Validating MediaInfo at: {mediainfo_path}") + + try: + with open(mediainfo_path, 'r', encoding='utf-8') as f: + for line in f: + if line.strip() == "General": + in_general = True + continue + if in_general: + if line.strip() == "": + break + if line.strip().startswith("Unique ID"): + unique_id = line.split(":", 1)[1].strip() + break + except FileNotFoundError: + console.print(f"[red]MediaInfo file not found: {mediainfo_path}[/red]") + return False + + if debug: + if unique_id: + console.print(f"[green]Found Unique ID: {unique_id}[/green]") + else: + console.print("[yellow]Unique ID not found in General section.[/yellow]") + + return bool(unique_id) + + +async def get_conformance_error(meta): + if not meta.get('is_disc') == "BDMV" and meta.get('mediainfo', {}).get('media', {}).get('track'): + general_track = next((track for track in meta['mediainfo']['media']['track'] + if track.get('@type') == 'General'), None) + if general_track and general_track.get('extra', {}).get('ConformanceErrors', {}): + try: + return True + except ValueError: + if meta['debug']: + console.print(f"[red]Unexpected value: {general_track['extra']['ConformanceErrors']}[/red]") + return True + else: + if meta['debug']: + console.print("[green]No Conformance errors found in MediaInfo General track[/green]") + return False + else: + return False diff --git a/src/get_desc.py b/src/get_desc.py new file mode 100644 index 000000000..baf788867 --- /dev/null +++ b/src/get_desc.py @@ -0,0 +1,121 @@ +import os +import urllib.parse +import requests +import glob +from src.console import console + + +async def gen_desc(meta): + def clean_text(text): + return text.replace('\r\n', '').replace('\n', '').strip() + + desclink = meta.get('desclink') + descfile = meta.get('descfile') + scene_nfo = False + bhd_nfo = False + + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'w', newline="", encoding='utf8') as description: + description.seek(0) + content_written = False + + if meta.get('desc_template'): + from jinja2 import Template + try: + with open(f"{meta['base_dir']}/data/templates/{meta['desc_template']}.txt", 'r') as f: + template = Template(f.read()) + template_desc = template.render(meta) + if clean_text(template_desc): + if len(template_desc) > 0: + description.write(template_desc + "\n") + content_written = True + except FileNotFoundError: + console.print(f"[ERROR] Template '{meta['desc_template']}' not found.") + + base_dir = meta['base_dir'] + uuid = meta['uuid'] + path = meta['path'] + specified_dir_path = os.path.join(base_dir, "tmp", uuid, "*.nfo") + source_dir_path = os.path.join(path, "*.nfo") + if meta.get('nfo') and not content_written: + if meta['debug']: + console.print(f"specified_dir_path: {specified_dir_path}") + console.print(f"sourcedir_path: {source_dir_path}") + if 'auto_nfo' in meta and meta['auto_nfo'] is True: + nfo_files = glob.glob(specified_dir_path) + scene_nfo = True + elif 'bhd_nfo' in meta and meta['bhd_nfo'] is True: + nfo_files = glob.glob(specified_dir_path) + bhd_nfo = True + else: + nfo_files = glob.glob(source_dir_path) + if not nfo_files: + console.print("NFO was set but no nfo file was found") + description.write("\n") + return meta + + if nfo_files: + nfo = nfo_files[0] + try: + with open(nfo, 'r', encoding="utf-8") as nfo_file: + nfo_content = nfo_file.read() + if meta['debug']: + console.print("NFO content read with utf-8 encoding.") + except UnicodeDecodeError: + if meta['debug']: + console.print("utf-8 decoding failed, trying latin1.") + with open(nfo, 'r', encoding="latin1") as nfo_file: + nfo_content = nfo_file.read() + + if scene_nfo is True: + description.write(f"[center][spoiler=Scene NFO:][code]{nfo_content}[/code][/spoiler][/center]\n") + elif bhd_nfo is True: + description.write(f"[center][spoiler=FraMeSToR NFO:][code]{nfo_content}[/code][/spoiler][/center]\n") + else: + description.write(f"[code]{nfo_content}[/code]\n") + meta['description'] = "CUSTOM" + content_written = True + + if desclink and not content_written: + try: + parsed = urllib.parse.urlparse(desclink.replace('/raw/', '/')) + split = os.path.split(parsed.path) + raw = parsed._replace(path=f"{split[0]}/raw/{split[1]}" if split[0] != '/' else f"/raw{parsed.path}") + raw_url = urllib.parse.urlunparse(raw) + desclink_content = requests.get(raw_url).text + if clean_text(desclink_content): + description.write(desclink_content + "\n") + meta['description'] = "CUSTOM" + content_written = True + except Exception as e: + console.print(f"[ERROR] Failed to fetch description from link: {e}") + + if descfile and os.path.isfile(descfile) and not content_written: + with open(descfile, 'r', encoding='utf-8') as f: + file_content = f.read() + if clean_text(file_content): + description.write(file_content) + meta['description'] = "CUSTOM" + content_written = True + + if not content_written: + if meta.get('description'): + description_text = meta.get('description', '').strip() + else: + description_text = "" + if description_text: + description.write(description_text + "\n") + + if description.tell() != 0: + description.write("\n") + + # Fallback if no description is provided + if not meta.get('skip_gen_desc', False) and not content_written: + description_text = meta['description'] if meta.get('description', '') else "" + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'w', newline="", encoding='utf8') as description: + if len(description_text) > 0: + description.write(description_text + "\n") + + if meta.get('description') in ('None', '', ' '): + meta['description'] = None + + return meta diff --git a/src/get_disc.py b/src/get_disc.py new file mode 100644 index 000000000..323442c6d --- /dev/null +++ b/src/get_disc.py @@ -0,0 +1,86 @@ +import os +import itertools +from src.discparse import DiscParse + + +async def get_disc(meta): + is_disc = None + videoloc = meta['path'] + bdinfo = None + bd_summary = None # noqa: F841 + discs = [] + parse = DiscParse() + for path, directories, files in sorted(os.walk(meta['path'])): + for each in directories: + if each.upper() == "BDMV": # BDMVs + is_disc = "BDMV" + disc = { + 'path': f"{path}/{each}", + 'name': os.path.basename(path), + 'type': 'BDMV', + 'summary': "", + 'bdinfo': "" + } + discs.append(disc) + elif each == "VIDEO_TS": # DVDs + is_disc = "DVD" + disc = { + 'path': f"{path}/{each}", + 'name': os.path.basename(path), + 'type': 'DVD', + 'vob_mi': '', + 'ifo_mi': '', + 'main_set': [], + 'size': "" + } + discs.append(disc) + elif each == "HVDVD_TS": + is_disc = "HDDVD" + disc = { + 'path': f"{path}/{each}", + 'name': os.path.basename(path), + 'type': 'HDDVD', + 'evo_mi': '', + 'largest_evo': "" + } + discs.append(disc) + if is_disc == "BDMV": + if meta.get('edit', False) is False: + discs, bdinfo = await parse.get_bdinfo(meta, discs, meta['uuid'], meta['base_dir'], meta.get('discs', [])) + else: + discs, bdinfo = await parse.get_bdinfo(meta, meta['discs'], meta['uuid'], meta['base_dir'], meta['discs']) + elif is_disc == "DVD" and not meta.get('emby', False): + discs = await parse.get_dvdinfo(discs, base_dir=meta['base_dir']) + export = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'w', newline="", encoding='utf-8') + export.write(discs[0]['ifo_mi']) + export.close() + export_clean = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'w', newline="", encoding='utf-8') + export_clean.write(discs[0]['ifo_mi']) + export_clean.close() + elif is_disc == "HDDVD": + discs = await parse.get_hddvd_info(discs, meta) + export = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'w', newline="", encoding='utf-8') + export.write(discs[0]['evo_mi']) + export.close() + discs = sorted(discs, key=lambda d: d['name']) + return is_disc, videoloc, bdinfo, discs + + +async def get_dvd_size(discs, manual_dvds): + sizes = [] + dvd_sizes = [] + for each in discs: + sizes.append(each['size']) + grouped_sizes = [list(i) for j, i in itertools.groupby(sorted(sizes))] + for each in grouped_sizes: + if len(each) > 1: + dvd_sizes.append(f"{len(each)}x{each[0]}") + else: + dvd_sizes.append(each[0]) + dvd_sizes.sort() + compact = " ".join(dvd_sizes) + + if manual_dvds: + compact = str(manual_dvds) + + return compact diff --git a/src/get_name.py b/src/get_name.py new file mode 100644 index 000000000..1d59bead9 --- /dev/null +++ b/src/get_name.py @@ -0,0 +1,446 @@ +import anitopy +import cli_ui +import os +import re +import sys + +from guessit import guessit + +from data.config import config +from src.cleanup import cleanup, reset_terminal +from src.console import console +from src.trackers.COMMON import COMMON + + +async def get_name(meta): + if "ULCX" in meta.get('trackers', []): + region, distributor = await missing_disc_info(meta) + if region and "SKIPPED" in region or distributor and "SKIPPED" in distributor: + meta['trackers'].remove("ULCX") + if distributor and 'SKIPPED' not in distributor: + meta['distributor'] = distributor + if region and 'SKIPPED' not in region: + meta['region'] = region + type = meta.get('type', "").upper() + title = meta.get('title', "") + alt_title = meta.get('aka', "") + year = meta.get('year', "") + if int(meta.get('manual_year')) > 0: + year = meta.get('manual_year') + resolution = meta.get('resolution', "") + if resolution == "OTHER": + resolution = "" + audio = meta.get('audio', "") + service = meta.get('service', "") + season = meta.get('season', "") + episode = meta.get('episode', "") + part = meta.get('part', "") + repack = meta.get('repack', "") + three_d = meta.get('3D', "") + tag = meta.get('tag', "") + source = meta.get('source', "") + uhd = meta.get('uhd', "") + hdr = meta.get('hdr', "") + hybrid = 'Hybrid' if meta.get('webdv', "") else "" + if meta.get('manual_episode_title'): + episode_title = meta.get('manual_episode_title') + elif meta.get('daily_episode_title'): + episode_title = meta.get('daily_episode_title') + else: + episode_title = "" + if meta.get('is_disc', "") == "BDMV": # Disk + video_codec = meta.get('video_codec', "") + region = meta.get('region', "") + elif meta.get('is_disc', "") == "DVD": + region = meta.get('region', "") + dvd_size = meta.get('dvd_size', "") + else: + video_codec = meta.get('video_codec', "") + video_encode = meta.get('video_encode', "") + edition = meta.get('edition', "") + if 'hybrid' in edition.upper(): + edition = edition.replace('Hybrid', '').strip() + + if meta['category'] == "TV": + if meta['search_year'] != "": + year = meta['year'] + else: + year = "" + if meta.get('manual_date'): + # Ignore season and year for --daily flagged shows, just use manual date stored in episode_name + season = '' + episode = '' + if meta.get('no_season', False) is True: + season = '' + if meta.get('no_year', False) is True: + year = '' + if meta.get('no_aka', False) is True: + alt_title = '' + if meta['debug']: + console.log("[cyan]get_name cat/type") + console.log(f"CATEGORY: {meta['category']}") + console.log(f"TYPE: {meta['type']}") + console.log("[cyan]get_name meta:") + # console.log(meta) + + # YAY NAMING FUN + if meta['category'] == "MOVIE": # MOVIE SPECIFIC + if type == "DISC": # Disk + if meta['is_disc'] == 'BDMV': + name = f"{title} {alt_title} {year} {three_d} {edition} {hybrid} {repack} {resolution} {region} {uhd} {source} {hdr} {video_codec} {audio}" + potential_missing = ['edition', 'region', 'distributor'] + elif meta['is_disc'] == 'DVD': + name = f"{title} {alt_title} {year} {edition} {repack} {source} {region} {dvd_size} {audio}" + potential_missing = ['edition', 'distributor'] + elif meta['is_disc'] == 'HDDVD': + name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {source} {video_codec} {audio}" + potential_missing = ['edition', 'region', 'distributor'] + elif type == "REMUX" and source in ("BluRay", "HDDVD"): # BluRay/HDDVD Remux + name = f"{title} {alt_title} {year} {three_d} {edition} {hybrid} {repack} {resolution} {uhd} {source} REMUX {hdr} {video_codec} {audio}" + potential_missing = ['edition', 'description'] + elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD"): # DVD Remux + name = f"{title} {alt_title} {year} {edition} {repack} {source} REMUX {audio}" + potential_missing = ['edition', 'description'] + elif type == "ENCODE": # Encode + name = f"{title} {alt_title} {year} {edition} {hybrid} {repack} {resolution} {uhd} {source} {audio} {hdr} {video_encode}" + potential_missing = ['edition', 'description'] + elif type == "WEBDL": # WEB-DL + name = f"{title} {alt_title} {year} {edition} {hybrid} {repack} {resolution} {uhd} {service} WEB-DL {audio} {hdr} {video_encode}" + potential_missing = ['edition', 'service'] + elif type == "WEBRIP": # WEBRip + name = f"{title} {alt_title} {year} {edition} {hybrid} {repack} {resolution} {uhd} {service} WEBRip {audio} {hdr} {video_encode}" + potential_missing = ['edition', 'service'] + elif type == "HDTV": # HDTV + name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {source} {audio} {video_encode}" + potential_missing = [] + elif type == "DVDRIP": + name = f"{title} {alt_title} {year} {source} {video_encode} DVDRip {audio}" + potential_missing = [] + elif meta['category'] == "TV": # TV SPECIFIC + if type == "DISC": # Disk + if meta['is_disc'] == 'BDMV': + name = f"{title} {year} {alt_title} {season}{episode} {three_d} {edition} {hybrid} {repack} {resolution} {region} {uhd} {source} {hdr} {video_codec} {audio}" + potential_missing = ['edition', 'region', 'distributor'] + if meta['is_disc'] == 'DVD': + name = f"{title} {year} {alt_title} {season}{episode}{three_d} {edition} {repack} {source} {region} {dvd_size} {audio}" + potential_missing = ['edition', 'distributor'] + elif meta['is_disc'] == 'HDDVD': + name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {source} {video_codec} {audio}" + potential_missing = ['edition', 'region', 'distributor'] + elif type == "REMUX" and source in ("BluRay", "HDDVD"): # BluRay Remux + name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {three_d} {edition} {hybrid} {repack} {resolution} {uhd} {source} REMUX {hdr} {video_codec} {audio}" # SOURCE + potential_missing = ['edition', 'description'] + elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD"): # DVD Remux + name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {repack} {source} REMUX {audio}" # SOURCE + potential_missing = ['edition', 'description'] + elif type == "ENCODE": # Encode + name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {hybrid} {repack} {resolution} {uhd} {source} {audio} {hdr} {video_encode}" # SOURCE + potential_missing = ['edition', 'description'] + elif type == "WEBDL": # WEB-DL + name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {hybrid} {repack} {resolution} {uhd} {service} WEB-DL {audio} {hdr} {video_encode}" + potential_missing = ['edition', 'service'] + elif type == "WEBRIP": # WEBRip + name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {hybrid} {repack} {resolution} {uhd} {service} WEBRip {audio} {hdr} {video_encode}" + potential_missing = ['edition', 'service'] + elif type == "HDTV": # HDTV + name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {repack} {resolution} {source} {audio} {video_encode}" + potential_missing = [] + elif type == "DVDRIP": + name = f"{title} {year} {alt_title} {season} {source} DVDRip {audio} {video_encode}" + potential_missing = [] + + try: + name = ' '.join(name.split()) + except Exception: + console.print("[bold red]Unable to generate name. Please re-run and correct any of the following args if needed.") + console.print(f"--category [yellow]{meta['category']}") + console.print(f"--type [yellow]{meta['type']}") + console.print(f"--source [yellow]{meta['source']}") + console.print("[bold green]If you specified type, try also specifying source") + + exit() + name_notag = name + name = name_notag + tag + clean_name = await clean_filename(name) + return name_notag, name, clean_name, potential_missing + + +async def clean_filename(name): + invalid = '<>:"/\\|?*' + for char in invalid: + name = name.replace(char, '-') + return name + + +async def extract_title_and_year(meta, filename): + basename = os.path.basename(filename) + basename = os.path.splitext(basename)[0] + + secondary_title = None + year = None + + # Check for AKA patterns first + aka_patterns = [' AKA ', '.aka.', ' aka ', '.AKA.'] + for pattern in aka_patterns: + if pattern in basename: + aka_parts = basename.split(pattern, 1) + if len(aka_parts) > 1: + primary_title = aka_parts[0].strip() + secondary_part = aka_parts[1].strip() + + # Look for a year in the primary title + year_match_primary = re.search(r'\b(19|20)\d{2}\b', primary_title) + if year_match_primary: + year = year_match_primary.group(0) + + # Process secondary title + secondary_match = re.match(r"^(\d+)", secondary_part) + if secondary_match: + secondary_title = secondary_match.group(1) + else: + # Catch everything after AKA until it hits a year or release info + year_or_release_match = re.search(r'\b(19|20)\d{2}\b|\bBluRay\b|\bREMUX\b|\b\d+p\b|\bDTS-HD\b|\bAVC\b', secondary_part) + if year_or_release_match: + # Check if we found a year in the secondary part + if re.match(r'\b(19|20)\d{2}\b', year_or_release_match.group(0)): + # If no year was found in primary title, or we want to override + if not year: + year = year_or_release_match.group(0) + + secondary_title = secondary_part[:year_or_release_match.start()].strip() + else: + secondary_title = secondary_part + + primary_title = primary_title.replace('.', ' ') + secondary_title = secondary_title.replace('.', ' ') + return primary_title, secondary_title, year + + # if not AKA, catch titles that begin with a year + year_start_match = re.match(r'^(19|20)\d{2}', basename) + if year_start_match: + title = year_start_match.group(0) + rest = basename[len(title):].lstrip('. _-') + # Look for another year in the rest of the title + year_match = re.search(r'\b(19|20)\d{2}\b', rest) + year = year_match.group(0) if year_match else None + if year: + return title, None, year + + folder_name = os.path.basename(meta['uuid']) if meta['uuid'] else "" + if meta['debug']: + console.print(f"[cyan]Extracting title and year from folder name: {folder_name}[/cyan]") + # lets do some subsplease handling + if 'subsplease' in folder_name.lower(): + parsed_title = anitopy.parse( + guessit(folder_name, {"excludes": ["country", "language"]})['title'] + )['anime_title'] + if parsed_title: + return parsed_title, None, None + + year_pattern = r'(18|19|20)\d{2}' + res_pattern = r'\b(480|576|720|1080|2160)[pi]\b' + type_pattern = r'(WEBDL|BluRay|REMUX|HDRip|Blu-Ray|Web-DL|webrip|web-rip|DVD|BD100|BD50|BD25|HDTV|UHD|HDR|DOVI|REPACK|Season)(?=[._\-\s]|$)' + season_pattern = r'\bS(\d{1,3})\b' + season_episode_pattern = r'\bS(\d{1,3})E(\d{1,3})\b' + date_pattern = r'\b(20\d{2})\.(\d{1,2})\.(\d{1,2})\b' + extension_pattern = r'\.(mkv|mp4)$' + + # Check for the specific pattern: year.year (e.g., "1970.2014") + double_year_pattern = r'\b(18|19|20)\d{2}\.(18|19|20)\d{2}\b' + double_year_match = re.search(double_year_pattern, folder_name) + + if double_year_match: + full_match = double_year_match.group(0) + years = full_match.split('.') + first_year = years[0] + second_year = years[1] + + if meta['debug']: + console.print(f"[cyan]Found double year pattern: {full_match}, using {second_year} as year[/cyan]") + + modified_folder_name = folder_name.replace(full_match, first_year) + year_match = None + res_match = re.search(res_pattern, modified_folder_name, re.IGNORECASE) + season_pattern_match = re.search(season_pattern, modified_folder_name, re.IGNORECASE) + season_episode_match = re.search(season_episode_pattern, modified_folder_name, re.IGNORECASE) + extension_match = re.search(extension_pattern, modified_folder_name, re.IGNORECASE) + type_match = re.search(type_pattern, modified_folder_name, re.IGNORECASE) + + indices = [('year', double_year_match.end(), second_year)] + if res_match: + indices.append(('res', res_match.start(), res_match.group())) + if season_pattern_match: + indices.append(('season', season_pattern_match.start(), season_pattern_match.group())) + if season_episode_match: + indices.append(('season_episode', season_episode_match.start(), season_episode_match.group())) + if extension_match: + indices.append(('extension', extension_match.start(), extension_match.group())) + if type_match: + indices.append(('type', type_match.start(), type_match.group())) + + folder_name_for_title = modified_folder_name + actual_year = second_year + + else: + date_match = re.search(date_pattern, folder_name) + year_match = re.search(year_pattern, folder_name) + res_match = re.search(res_pattern, folder_name, re.IGNORECASE) + season_pattern_match = re.search(season_pattern, folder_name, re.IGNORECASE) + season_episode_match = re.search(season_episode_pattern, folder_name, re.IGNORECASE) + extension_match = re.search(extension_pattern, folder_name, re.IGNORECASE) + type_match = re.search(type_pattern, folder_name, re.IGNORECASE) + + indices = [] + if date_match: + indices.append(('date', date_match.start(), date_match.group())) + if year_match and not date_match: + indices.append(('year', year_match.start(), year_match.group())) + if res_match: + indices.append(('res', res_match.start(), res_match.group())) + if season_pattern_match: + indices.append(('season', season_pattern_match.start(), season_pattern_match.group())) + if season_episode_match: + indices.append(('season_episode', season_episode_match.start(), season_episode_match.group())) + if extension_match: + indices.append(('extension', extension_match.start(), extension_match.group())) + if type_match: + indices.append(('type', type_match.start(), type_match.group())) + + folder_name_for_title = folder_name + actual_year = year_match.group() if year_match and not date_match else None + + if indices: + indices.sort(key=lambda x: x[1]) + first_type, first_index, first_value = indices[0] + title_part = folder_name_for_title[:first_index] + title_part = re.sub(r'[\.\-_ ]+$', '', title_part) + # Handle unmatched opening parenthesis + if title_part.count('(') > title_part.count(')'): + paren_pos = title_part.rfind('(') + content_after_paren = folder_name[paren_pos + 1:first_index].strip() + + if content_after_paren: + secondary_title = content_after_paren + + title_part = title_part[:paren_pos].rstrip() + else: + title_part = folder_name + + replacements = { + '_': ' ', + '.': ' ', + 'DVD9': '', + 'DVD5': '', + 'DVDR': '', + 'BDR': '', + 'HDDVD': '', + 'WEB-DL': '', + 'WEBRip': '', + 'WEB': '', + 'BluRay': '', + 'Blu-ray': '', + 'HDTV': '', + 'DVDRip': '', + 'REMUX': '', + 'HDR': '', + 'UHD': '', + '4K': '', + 'DVD': '', + 'HDRip': '', + 'BDMV': '', + 'R1': '', + 'R2': '', + 'R3': '', + 'R4': '', + 'R5': '', + 'R6': '', + "Director's Cut": '', + "Extended Edition": '', + "directors cut": '', + "director cut": '', + "itunes": '', + } + filename = re.sub(r'\s+', ' ', filename) + filename = await multi_replace(title_part, replacements) + secondary_title = await multi_replace(secondary_title or '', replacements) + if not secondary_title: + secondary_title = None + if filename: + # Look for content in parentheses + bracket_pattern = r'\s*\(([^)]+)\)\s*' + bracket_match = re.search(bracket_pattern, filename) + + if bracket_match: + bracket_content = bracket_match.group(1).strip() + bracket_content = await multi_replace(bracket_content, replacements) + + # Only add to secondary_title if we don't already have one + if not secondary_title and bracket_content: + secondary_title = bracket_content + secondary_title = re.sub(r'[\.\-_ ]+$', '', secondary_title) + + filename = re.sub(bracket_pattern, ' ', filename) + filename = re.sub(r'\s+', ' ', filename).strip() + + if filename: + return filename, secondary_title, actual_year + + # If no pattern match works but there's still a year in the filename, extract it + year_match = re.search(r'(?= cooldown_seconds: + available.append(tracker) + else: + wait_time = cooldown_seconds - time_since_last + waiting.append((tracker, wait_time)) + + return available, waiting + + +async def get_tracker_data(video, meta, search_term=None, search_file_folder=None, cat=None, only_id=False): + found_match = False + base_dir = meta['base_dir'] + if search_term: + # Check if a specific tracker is already set in meta + if not meta.get('emby', False): + tracker_keys = { + 'aither': 'AITHER', + 'blu': 'BLU', + 'lst': 'LST', + 'ulcx': 'ULCX', + 'oe': 'OE', + 'huno': 'HUNO', + 'ant': 'ANT', + 'btn': 'BTN', + 'bhd': 'BHD', + 'hdb': 'HDB', + 'rf': 'RF', + 'otw': 'OTW', + 'yus': 'YUS', + 'dp': 'DP', + 'ptp': 'PTP', + } + else: + # Preference trackers with lesser overall torrents + # Leaving the more complete trackers free when really needed + tracker_keys = { + 'sp': 'SP', + 'otw': 'OTW', + 'dp': 'DP', + 'yus': 'YUS', + 'rf': 'RF', + 'oe': 'OE', + 'ulcx': 'ULCX', + 'huno': 'HUNO', + 'lst': 'LST', + 'ant': 'ANT', + 'hdb': 'HDB', + 'bhd': 'BHD', + 'blu': 'BLU', + 'aither': 'AITHER', + 'btn': 'BTN', + 'ptp': 'PTP', + } + + specific_tracker = [tracker_keys[key] for key in tracker_keys if meta.get(key) is not None] + + if specific_tracker: + if meta.get('is_disc', False) and "ANT" in specific_tracker: + specific_tracker.remove("ANT") + if meta.get('category') == "MOVIE" and "BTN" in specific_tracker: + specific_tracker.remove("BTN") + + async def process_tracker(tracker_name, meta, only_id): + nonlocal found_match + if tracker_class_map is None: + print(f"Tracker class for {tracker_name} not found.") + return meta + + tracker_instance = tracker_class_map[tracker_name](config=config) + try: + updated_meta, match = await update_metadata_from_tracker( + tracker_name, tracker_instance, meta, search_term, search_file_folder, only_id + ) + if match: + found_match = True + if meta.get('debug'): + console.print(f"[green]Match found on tracker: {tracker_name}[/green]") + meta['matched_tracker'] = tracker_name + await save_tracker_timestamp(tracker_name, base_dir=base_dir) + return updated_meta + except aiohttp.ClientSSLError: + await save_tracker_timestamp(tracker_name, base_dir=base_dir) + print(f"{tracker_name} tracker request failed due to SSL error.") + except requests.exceptions.ConnectionError as conn_err: + await save_tracker_timestamp(tracker_name, base_dir=base_dir) + print(f"{tracker_name} tracker request failed due to connection error: {conn_err}") + return meta + + while not found_match and specific_tracker: + available_trackers, waiting_trackers = await get_available_trackers(specific_tracker, base_dir, debug=meta['debug']) + + if available_trackers: + if meta['debug'] or meta.get('emby', False): + console.print(f"[green]Available trackers: {', '.join(available_trackers)}[/green]") + tracker_to_process = available_trackers[0] + else: + if waiting_trackers: + waiting_trackers.sort(key=lambda x: x[1]) + tracker_to_process, wait_time = waiting_trackers[0] + + cooldown_info = ", ".join( + f"{tracker} ({wait_time:.1f}s)" for tracker, wait_time in waiting_trackers + ) + for remaining in range(int(wait_time), -1, -1): + msg = (f"[yellow]All specific trackers in cooldown. " + f"Waiting {remaining:.1f} seconds for {tracker_to_process}. " + f"Cooldowns: {cooldown_info}[/yellow]") + console.print(msg, end='\r') + time.sleep(1) + console.print() + + else: + if meta['debug']: + console.print("[red]No specific trackers available[/red]") + break + + # Process the selected tracker + if tracker_to_process == "BTN": + btn_id = meta.get('btn') + btn_api = config['DEFAULT'].get('btn_api') + if btn_api and len(btn_api) > 25: + await get_btn_torrents(btn_api, btn_id, meta) + if meta.get('imdb_id') != 0: + found_match = True + meta['matched_tracker'] = "BTN" + await save_tracker_timestamp("BTN", base_dir=base_dir) + elif tracker_to_process == "ANT": + imdb_tmdb_list = await tracker_class_map['ANT'](config=config).get_data_from_files(meta) + console.print(f"[green]ANT tracker data found: {imdb_tmdb_list}[/green]") + if imdb_tmdb_list: + for d in imdb_tmdb_list: + meta.update(d) + found_match = True + meta['matched_tracker'] = "ANT" + await save_tracker_timestamp("ANT", base_dir=base_dir) + else: + meta = await process_tracker(tracker_to_process, meta, only_id) + + if not found_match: + if tracker_to_process in specific_tracker: + specific_tracker.remove(tracker_to_process) + remaining_available, remaining_waiting = await get_available_trackers(specific_tracker, base_dir, debug=meta['debug']) + + if remaining_available or remaining_waiting: + if meta['debug'] or meta.get('emby', False): + console.print(f"[yellow]No match found with {tracker_to_process}. Checking remaining trackers...[/yellow]") + else: + if meta['debug']: + console.print(f"[yellow]No match found with {tracker_to_process}. No more trackers available to check.[/yellow]") + break + + if found_match: + if meta.get('debug'): + console.print(f"[green]Successfully found match using tracker: {meta.get('matched_tracker', 'Unknown')}[/green]") + else: + if meta['debug']: + console.print("[yellow]No matches found on any available specific trackers.[/yellow]") + + else: + # Process all trackers with API = true if no specific tracker is set in meta + tracker_order = ["PTP", "HDB", "BHD", "BLU", "AITHER", "HUNO", "LST", "OE", "ULCX"] + + if cat == "TV" or meta.get('category') == "TV": + if meta['debug']: + console.print("[yellow]Detected TV content, skipping PTP tracker check") + tracker_order = [tracker for tracker in tracker_order if tracker != "PTP"] + + async def process_tracker(tracker_name, meta, only_id): + nonlocal found_match + if tracker_class_map is None: + print(f"Tracker class for {tracker_name} not found.") + return meta + + tracker_instance = tracker_class_map[tracker_name](config=config) + try: + updated_meta, match = await update_metadata_from_tracker( + tracker_name, tracker_instance, meta, search_term, search_file_folder, only_id + ) + if match: + found_match = True + if meta.get('debug'): + console.print(f"[green]Match found on tracker: {tracker_name}[/green]") + meta['matched_tracker'] = tracker_name + return updated_meta + except aiohttp.ClientSSLError: + print(f"{tracker_name} tracker request failed due to SSL error.") + except requests.exceptions.ConnectionError as conn_err: + print(f"{tracker_name} tracker request failed due to connection error: {conn_err}") + return meta + + for tracker_name in tracker_order: + if not found_match: # Stop checking once a match is found + tracker_config = config['TRACKERS'].get(tracker_name, {}) + if str(tracker_config.get('useAPI', 'false')).lower() == "true": + meta = await process_tracker(tracker_name, meta, only_id) + + if not found_match: + meta['no_tracker_match'] = True + if meta['debug']: + console.print("[yellow]No matches found on any trackers.[/yellow]") + + else: + console.print("[yellow]Warning: No valid search term available, skipping tracker updates.[/yellow]") + + return meta + + +async def ping_unit3d(meta): + from src.trackers.COMMON import COMMON + common = COMMON(config) + import re + + # Prioritize trackers in this order + tracker_order = ["BLU", "AITHER", "ULCX", "LST", "OE"] + + # Check if we have stored torrent comments + if meta.get('torrent_comments'): + # Try to extract tracker IDs from stored comments + for tracker_name in tracker_order: + # Skip if we already have region and distributor + if meta.get('region') and meta.get('distributor'): + if meta.get('debug', False): + console.print(f"[green]Both region ({meta['region']}) and distributor ({meta['distributor']}) found - no need to check more trackers[/green]") + break + + tracker_id = None + tracker_key = tracker_name.lower() + # Check each stored comment for matching tracker URL + for comment_data in meta.get('torrent_comments', []): + comment = comment_data.get('comment', '') + + if "blutopia.cc" in comment and tracker_name == "BLU": + match = re.search(r'/(\d+)$', comment) + if match: + tracker_id = match.group(1) + meta[tracker_key] = tracker_id + break + elif "aither.cc" in comment and tracker_name == "AITHER": + match = re.search(r'/(\d+)$', comment) + if match: + tracker_id = match.group(1) + meta[tracker_key] = tracker_id + break + elif "lst.gg" in comment and tracker_name == "LST": + match = re.search(r'/(\d+)$', comment) + if match: + tracker_id = match.group(1) + meta[tracker_key] = tracker_id + break + elif "onlyencodes.cc" in comment and tracker_name == "OE": + match = re.search(r'/(\d+)$', comment) + if match: + tracker_id = match.group(1) + meta[tracker_key] = tracker_id + break + elif "https://upload.cx" in comment and tracker_name == "ULCX": + match = re.search(r'/(\d+)$', comment) + if match: + tracker_id = match.group(1) + meta[tracker_key] = tracker_id + break + + # If we found a tracker ID, try to get region/distributor data + if tracker_id: + missing_info = [] + if not meta.get('region'): + missing_info.append("region") + if not meta.get('distributor'): + missing_info.append("distributor") + + if meta.get('debug', False): + console.print(f"[cyan]Using {tracker_name} ID {tracker_id} to get {'/'.join(missing_info)} info[/cyan]") + + tracker_instance = tracker_class_map[tracker_name](config=config) + + # Store initial state to detect changes + had_region = bool(meta.get('region')) + had_distributor = bool(meta.get('distributor')) + await common.unit3d_region_distributor(meta, tracker_name, tracker_instance.torrent_url, tracker_id) + + if meta.get('region') and not had_region: + if meta.get('debug', False): + console.print(f"[green]Found region '{meta['region']}' from {tracker_name}[/green]") + + if meta.get('distributor') and not had_distributor: + if meta.get('debug', False): + console.print(f"[green]Found distributor '{meta['distributor']}' from {tracker_name}[/green]") diff --git a/src/getseasonep.py b/src/getseasonep.py new file mode 100644 index 000000000..3665b66d4 --- /dev/null +++ b/src/getseasonep.py @@ -0,0 +1,248 @@ +from src.console import console +from guessit import guessit +import anitopy +from pathlib import Path +import asyncio +import requests +import os +import re +from difflib import SequenceMatcher +from src.tmdb import get_tmdb_id, daily_to_tmdb_season_episode, get_romaji +from src.exceptions import * # noqa: F403 + + +async def get_season_episode(video, meta): + if meta['category'] == 'TV': + filelist = meta['filelist'] + meta['tv_pack'] = 0 + is_daily = False + if not meta.get('anime'): + try: + daily_match = re.search(r"\d{4}[-\.]\d{2}[-\.]\d{2}", video) + if meta.get('manual_date') or daily_match: + # Handle daily episodes + # The user either provided the --daily argument or a date was found in the filename + + if meta.get('manual_date') is None and daily_match is not None: + meta['manual_date'] = daily_match.group().replace('.', '-') + is_daily = True + guess_date = meta.get('manual_date', guessit(video).get('date')) if meta.get('manual_date') else guessit(video).get('date') + season_int, episode_int = await daily_to_tmdb_season_episode(meta.get('tmdb_id'), guess_date) + + season = f"S{str(season_int).zfill(2)}" + episode = f"E{str(episode_int).zfill(2)}" + # For daily shows, pass the supplied date as the episode title + # Season and episode will be stripped later to conform with standard daily episode naming format + meta['daily_episode_title'] = meta.get('manual_date') + + else: + try: + guess_year = guessit(video)['year'] + except Exception: + guess_year = "" + try: + if guessit(video)["season"] == guess_year: + if f"s{guessit(video)['season']}" in video.lower(): + season_int = str(guessit(video)["season"]) + season = "S" + season_int.zfill(2) + else: + season_int = "1" + season = "S01" + else: + season_int = str(guessit(video)["season"]) + season = "S" + season_int.zfill(2) + except Exception: + console.print("[bold yellow]There was an error guessing the season number. Guessing S01. Use [bold green]--season #[/bold green] to correct if needed") + season_int = "1" + season = "S01" + + except Exception: + console.print_exception() + season_int = "1" + season = "S01" + + try: + if is_daily is not True: + episodes = "" + if len(filelist) == 1: + episodes = guessit(video)['episode'] + if isinstance(episodes, list): + episode = "" + for item in guessit(video)["episode"]: + ep = (str(item).zfill(2)) + episode += f"E{ep}" + episode_int = episodes[0] + else: + episode_int = str(episodes) + episode = "E" + str(episodes).zfill(2) + else: + episode = "" + episode_int = "0" + meta['tv_pack'] = 1 + except Exception: + episode = "" + episode_int = "0" + meta['tv_pack'] = 1 + + else: + # If Anime + # if the mal id is set, then we've already run get_romaji in tmdb.py + if meta.get('mal_id') == 0 and meta['category'] == "TV": + parsed = anitopy.parse(Path(video).name) + romaji, mal_id, eng_title, seasonYear, anilist_episodes, meta['demographic'] = await get_romaji(parsed['anime_title'], meta.get('mal_id', 0), meta) + if mal_id: + meta['mal_id'] = mal_id + if meta.get('tmdb_id') == 0: + year = parsed.get('anime_year', str(seasonYear)) + meta = await get_tmdb_id(guessit(parsed['anime_title'], {"excludes": ["country", "language"]})['title'], year, meta, meta['category']) + # meta = await tmdb_other_meta(meta) + if meta.get('mal_id') != 0 and meta['category'] == "TV": + parsed = anitopy.parse(Path(video).name) + tag = parsed.get('release_group', "") + if tag != "" and meta.get('tag') is None: + meta['tag'] = f"-{tag}" + if len(filelist) == 1: + try: + episodes = parsed.get('episode_number', guessit(video).get('episode', '1')) + if not isinstance(episodes, list) and not episodes.isnumeric(): + episodes = guessit(video)['episode'] + if isinstance(episodes, list): + episode_int = int(episodes[0]) # Always convert to integer + episode = "".join([f"E{str(int(item)).zfill(2)}" for item in episodes]) + else: + episode_int = int(episodes) # Convert to integer + episode = f"E{str(episode_int).zfill(2)}" + except Exception: + episode_int = 1 + episode = "E01" + + if meta.get('uuid'): + # Look for episode patterns in uuid + episode_patterns = [ + r'[Ee](\d+)[Ee](\d+)', + r'[Ee](\d+)', + r'[Ee]pisode[\s_]*(\d+)', + r'[\s_\-](\d+)[\s_\-]', + r'[\s_\-](\d+)$', + r'^(\d+)[\s_\-]', + ] + + for pattern in episode_patterns: + match = re.search(pattern, meta['uuid'], re.IGNORECASE) + if match: + try: + episode_int = int(match.group(1)) + episode = f"E{str(episode_int).zfill(2)}" + break + except (ValueError, IndexError): + continue + + if episode_int == 1: # Still using fallback + console.print('[bold yellow]There was an error guessing the episode number. Guessing E01. Use [bold green]--episode #[/bold green] to correct if needed') + + await asyncio.sleep(1.5) + else: + episode = "" + episode_int = 0 # Ensure it's an integer + meta['tv_pack'] = 1 + + try: + if meta.get('season_int'): + season_int = int(meta.get('season_int')) # Convert to integer + else: + season = parsed.get('anime_season', guessit(video).get('season', '1')) + season_int = int(season) # Convert to integer + season = f"S{str(season_int).zfill(2)}" + except Exception: + try: + if episode_int >= anilist_episodes: + params = { + 'id': str(meta['tvdb_id']), + 'origin': 'tvdb', + 'absolute': str(episode_int), + } + url = "https://thexem.info/map/single" + response = requests.post(url, params=params).json() + if response['result'] == "failure": + raise XEMNotFound # noqa: F405 + if meta['debug']: + console.log(f"[cyan]TheXEM Absolute -> Standard[/cyan]\n{response}") + season_int = int(response['data']['scene']['season']) # Convert to integer + season = f"S{str(season_int).zfill(2)}" + if len(filelist) == 1: + episode_int = int(response['data']['scene']['episode']) # Convert to integer + episode = f"E{str(episode_int).zfill(2)}" + else: + season_int = 1 # Default to 1 if error occurs + season = "S01" + names_url = f"https://thexem.info/map/names?origin=tvdb&id={str(meta['tvdb_id'])}" + names_response = requests.get(names_url).json() + if meta['debug']: + console.log(f'[cyan]Matching Season Number from TheXEM\n{names_response}') + difference = 0 + if names_response['result'] == "success": + for season_num, values in names_response['data'].items(): + for lang, names in values.items(): + if lang == "jp": + for name in names: + romaji_check = re.sub(r"[^0-9a-zA-Z\[\\]]+", "", romaji.lower().replace(' ', '')) + name_check = re.sub(r"[^0-9a-zA-Z\[\\]]+", "", name.lower().replace(' ', '')) + diff = SequenceMatcher(None, romaji_check, name_check).ratio() + if romaji_check in name_check and diff >= difference: + season_int = int(season_num) if season_num != "all" else 1 # Convert to integer + season = f"S{str(season_int).zfill(2)}" + difference = diff + if lang == "us": + for name in names: + eng_check = re.sub(r"[^0-9a-zA-Z\[\\]]+", "", eng_title.lower().replace(' ', '')) + name_check = re.sub(r"[^0-9a-zA-Z\[\\]]+", "", name.lower().replace(' ', '')) + diff = SequenceMatcher(None, eng_check, name_check).ratio() + if eng_check in name_check and diff >= difference: + season_int = int(season_num) if season_num != "all" else 1 # Convert to integer + season = f"S{str(season_int).zfill(2)}" + difference = diff + else: + raise XEMNotFound # noqa: F405 + except Exception: + if meta['debug']: + console.print_exception() + try: + season = guessit(video).get('season', '1') + season_int = int(season) # Convert to integer + except Exception: + season_int = 1 # Default to 1 if error occurs + season = "S01" + console.print(f"[bold yellow]{meta['title']} does not exist on thexem, guessing {season}") + console.print(f"[bold yellow]If [green]{season}[/green] is incorrect, use --season to correct") + await asyncio.sleep(3) + else: + return meta + + if meta.get('manual_season', None) is None: + meta['season'] = season + else: + season_int = meta['manual_season'].lower().replace('s', '') + meta['season'] = f"S{meta['manual_season'].lower().replace('s', '').zfill(2)}" + if meta.get('manual_episode', None) is None: + meta['episode'] = episode + else: + episode_int = meta['manual_episode'].lower().replace('e', '') + meta['episode'] = f"E{meta['manual_episode'].lower().replace('e', '').zfill(2)}" + meta['tv_pack'] = 0 + + # if " COMPLETE " in Path(video).name.replace('.', ' '): + # meta['season'] = "COMPLETE" + meta['season_int'] = season_int + meta['episode_int'] = episode_int + + # Manual episode title + if 'manual_episode_title' in meta and meta['manual_episode_title'] == "": + meta['episode_title'] = meta.get('manual_episode_title') + + # Guess the part of the episode (if available) + meta['part'] = "" + if meta['tv_pack'] == 1: + part = guessit(os.path.dirname(video)).get('part') + meta['part'] = f"Part {part}" if part else "" + + return meta diff --git a/src/imdb.py b/src/imdb.py new file mode 100644 index 000000000..73689d5a7 --- /dev/null +++ b/src/imdb.py @@ -0,0 +1,930 @@ +import asyncio +import cli_ui +import httpx +import json +import sys + +from anitopy import parse as anitopy_parse +from datetime import datetime +from difflib import SequenceMatcher +from guessit import guessit + +from src.cleanup import cleanup, reset_terminal +from src.console import console + + +async def safe_get(data, path, default=None): + for key in path: + if isinstance(data, dict): + data = data.get(key, default) + else: + return default + return data + + +async def get_imdb_info_api(imdbID, manual_language=None, debug=False): + imdb_info = {} + + if not imdbID or imdbID == 0: + imdb_info['type'] = None + return imdb_info + + try: + if not str(imdbID).startswith("tt"): + imdbID = f"tt{imdbID:07d}" + except Exception as e: + console.print(f"[red]Error:[/red] {e}") + return imdb_info + + query = { + "query": f""" + query GetTitleInfo {{ + title(id: "{imdbID}") {{ + id + titleText {{ + text + isOriginalTitle + country {{ + text + }} + }} + originalTitleText {{ + text + }} + releaseYear {{ + year + endYear + }} + titleType {{ + id + }} + plot {{ + plotText {{ + plainText + }} + }} + ratingsSummary {{ + aggregateRating + voteCount + }} + primaryImage {{ + url + }} + runtime {{ + displayableProperty {{ + value {{ + plainText + }} + }} + seconds + }} + titleGenres {{ + genres {{ + genre {{ + text + }} + }} + }} + principalCredits {{ + category {{ + text + id + }} + credits {{ + name {{ + id + nameText {{ + text + }} + }} + }} + }} + episodes {{ + episodes(first: 500) {{ + edges {{ + node {{ + id + series {{ + displayableEpisodeNumber {{ + displayableSeason {{ + season + }} + episodeNumber {{ + text + }} + }} + }} + titleText {{ + text + }} + releaseYear {{ + year + }} + releaseDate {{ + year + month + day + }} + }} + }} + pageInfo {{ + hasNextPage + hasPreviousPage + }} + total + }} + }} + runtimes(first: 10) {{ + edges {{ + node {{ + id + seconds + displayableProperty {{ + value {{ + plainText + }} + }} + attributes {{ + text + }} + }} + }} + }} + technicalSpecifications {{ + aspectRatios {{ + items {{ + aspectRatio + attributes {{ + text + }} + }} + }} + cameras {{ + items {{ + camera + attributes {{ + text + }} + }} + }} + colorations {{ + items {{ + text + attributes {{ + text + }} + }} + }} + laboratories {{ + items {{ + laboratory + attributes {{ + text + }} + }} + }} + negativeFormats {{ + items {{ + negativeFormat + attributes {{ + text + }} + }} + }} + printedFormats {{ + items {{ + printedFormat + attributes {{ + text + }} + }} + }} + processes {{ + items {{ + process + attributes {{ + text + }} + }} + }} + soundMixes {{ + items {{ + text + attributes {{ + text + }} + }} + }} + filmLengths {{ + items {{ + filmLength + countries {{ + text + }} + numReels + }} + }} + }} + akas(first: 100) {{ + edges {{ + node {{ + text + country {{ + text + }} + language {{ + text + }} + }} + }} + }} + countriesOfOrigin {{ + countries {{ + text + }} + }} + }} + }} + """ + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post("https://api.graphql.imdb.com/", json=query, headers={"Content-Type": "application/json"}, timeout=10) + response.raise_for_status() + data = response.json() + except httpx.HTTPStatusError as e: + console.print(f"[red]IMDb API error: {e.response.status_code}[/red]") + return imdb_info + except httpx.RequestError as e: + console.print(f"[red]IMDb API Network error: {e}[/red]") + return imdb_info + + title_data = await safe_get(data, ["data", "title"], {}) + if not title_data: + return imdb_info # Return empty if no data found + + imdb_info['imdbID'] = imdbID + imdb_info['title'] = await safe_get(title_data, ['titleText', 'text']) + countries_list = await safe_get(title_data, ['countriesOfOrigin', 'countries'], []) + if isinstance(countries_list, list) and countries_list: + # First country for 'country' + imdb_info['country'] = countries_list[0].get('text', '') + # All countries joined for 'country_list' + imdb_info['country_list'] = ', '.join([c.get('text', '') for c in countries_list if isinstance(c, dict) and 'text' in c]) + else: + imdb_info['country'] = '' + imdb_info['country_list'] = '' + imdb_info['year'] = await safe_get(title_data, ['releaseYear', 'year']) + imdb_info['end_year'] = await safe_get(title_data, ['releaseYear', 'endYear']) + original_title = await safe_get(title_data, ['originalTitleText', 'text'], '') + imdb_info['aka'] = original_title if original_title and original_title != imdb_info['title'] else imdb_info['title'] + imdb_info['type'] = await safe_get(title_data, ['titleType', 'id'], None) + runtime_seconds = await safe_get(title_data, ['runtime', 'seconds'], 0) + imdb_info['runtime'] = str(runtime_seconds // 60 if runtime_seconds else 60) + imdb_info['cover'] = await safe_get(title_data, ['primaryImage', 'url']) + imdb_info['plot'] = await safe_get(title_data, ['plot', 'plotText', 'plainText'], 'No plot available') + + genres = await safe_get(title_data, ['titleGenres', 'genres'], []) + genre_list = [await safe_get(g, ['genre', 'text'], '') for g in genres] + imdb_info['genres'] = ', '.join(filter(None, genre_list)) + + imdb_info['rating'] = await safe_get(title_data, ['ratingsSummary', 'aggregateRating'], 'N/A') + + async def get_credits(title_data, category_keyword): + people_list = [] + people_id_list = [] + principal_credits = await safe_get(title_data, ['principalCredits'], []) + + if not isinstance(principal_credits, list): + return people_list, people_id_list + + for pc in principal_credits: + category_text = await safe_get(pc, ['category', 'text'], '') + + if category_keyword in category_text: + credits = await safe_get(pc, ['credits'], []) + for c in credits: + name_obj = await safe_get(c, ['name'], {}) + person_id = await safe_get(name_obj, ['id'], '') + person_name = await safe_get(name_obj, ['nameText', 'text'], '') + + if person_id and person_name: + people_list.append(person_name) + people_id_list.append(person_id) + break + + return people_list, people_id_list + + imdb_info['directors'], imdb_info['directors_id'] = await get_credits(title_data, 'Direct') + imdb_info['creators'], imdb_info['creators_id'] = await get_credits(title_data, 'Creat') + imdb_info['writers'], imdb_info['writers_id'] = await get_credits(title_data, 'Writ') + imdb_info['stars'], imdb_info['stars_id'] = await get_credits(title_data, 'Star') + + editions = await safe_get(title_data, ['runtimes', 'edges'], []) + if editions: + edition_list = [] + imdb_info['edition_details'] = {} + + for edge in editions: + node = await safe_get(edge, ['node'], {}) + seconds = await safe_get(node, ['seconds'], 0) + minutes = seconds // 60 if seconds else 0 + displayable_property = await safe_get(node, ['displayableProperty', 'value', 'plainText'], '') + attributes = await safe_get(node, ['attributes'], []) + attribute_texts = [attr.get('text') for attr in attributes if isinstance(attr, dict)] if attributes else [] + + edition_display = f"{displayable_property} ({minutes} min)" + if attribute_texts: + edition_display += f" [{', '.join(attribute_texts)}]" + + if seconds and displayable_property: + edition_list.append(edition_display) + + runtime_key = str(minutes) + imdb_info['edition_details'][runtime_key] = { + 'display_name': displayable_property, + 'seconds': seconds, + 'minutes': minutes, + 'attributes': attribute_texts + } + + imdb_info['editions'] = ', '.join(edition_list) + + akas_edges = await safe_get(title_data, ['akas', 'edges'], default=[]) + imdb_info['akas'] = [ + { + "title": await safe_get(edge, ['node', 'text']), + "country": await safe_get(edge, ['node', 'country', 'text']), + "language": await safe_get(edge, ['node', 'language', 'text']), + } + for edge in akas_edges + ] + + if manual_language: + imdb_info['original_language'] = manual_language + + imdb_info['episodes'] = [] + episodes_data = await safe_get(title_data, ['episodes', 'episodes'], None) + if episodes_data: + edges = await safe_get(episodes_data, ['edges'], []) + for edge in edges: + node = await safe_get(edge, ['node'], {}) + + series_info = await safe_get(node, ['series', 'displayableEpisodeNumber'], {}) + season_info = await safe_get(series_info, ['displayableSeason'], {}) + episode_number_info = await safe_get(series_info, ['episodeNumber'], {}) + + episode_info = { + 'id': await safe_get(node, ['id'], ''), + 'title': await safe_get(node, ['titleText', 'text'], 'Unknown Title'), + 'release_year': await safe_get(node, ['releaseYear', 'year'], 'Unknown Year'), + 'release_date': { + 'year': await safe_get(node, ['releaseDate', 'year'], None), + 'month': await safe_get(node, ['releaseDate', 'month'], None), + 'day': await safe_get(node, ['releaseDate', 'day'], None), + }, + 'season': await safe_get(season_info, ['season'], 'unknown'), + 'episode_number': await safe_get(episode_number_info, ['text'], '') + } + imdb_info['episodes'].append(episode_info) + + if imdb_info['episodes']: + seasons_data = {} + + for episode in imdb_info['episodes']: + season_str = episode.get('season', 'unknown') + release_year = episode.get('release_year') + + try: + season_int = int(season_str) if season_str != 'unknown' and season_str else None + except (ValueError, TypeError): + season_int = None + + if season_int is not None and release_year and isinstance(release_year, int): + if season_int not in seasons_data: + seasons_data[season_int] = set() + seasons_data[season_int].add(release_year) + + seasons_summary = [] + for season_num in sorted(seasons_data.keys()): + years = sorted(list(seasons_data[season_num])) + season_entry = { + 'season': season_num, + 'year': years[0], + 'year_range': f"{years[0]}" if len(years) == 1 else f"{years[0]}-{years[-1]}" + } + seasons_summary.append(season_entry) + + imdb_info['seasons_summary'] = seasons_summary + else: + imdb_info['seasons_summary'] = [] + + sound_mixes = await safe_get(title_data, ['technicalSpecifications', 'soundMixes', 'items'], []) + imdb_info['sound_mixes'] = [sm.get('text', '') for sm in sound_mixes if isinstance(sm, dict) and 'text' in sm] + + episodes = imdb_info.get('episodes', []) + current_year = datetime.now().year + release_years = [episode['release_year'] for episode in episodes if 'release_year' in episode and isinstance(episode['release_year'], int)] + if imdb_info['end_year']: + imdb_info['tv_year'] = imdb_info['end_year'] + elif release_years: + closest_year = min(release_years, key=lambda year: abs(year - current_year)) + imdb_info['tv_year'] = closest_year + else: + imdb_info['tv_year'] = None + + if debug: + console.print(f"[yellow]IMDb Response: {json.dumps(imdb_info, indent=2)[:1000]}...[/yellow]") + + return imdb_info + + +async def search_imdb(filename, search_year, quickie=False, category=None, debug=False, secondary_title=None, path=None, untouched_filename=None, attempted=0, duration=None, unattended=False): + search_results = [] + imdbID = imdb_id = 0 + if attempted is None: + attempted = 0 + if debug: + console.print(f"[yellow]Searching IMDb for {filename} and year {search_year}...[/yellow]") + if attempted: + await asyncio.sleep(1) # Whoa baby, slow down + + async def run_imdb_search(filename, search_year, category=None, debug=False, attempted=0, duration=None, wide_search=False): + search_results = [] + if secondary_title is not None: + filename = secondary_title + if attempted is None: + attempted = 0 + if attempted: + await asyncio.sleep(1) # Whoa baby, slow down + url = "https://api.graphql.imdb.com/" + if category == "MOVIE": + filename = filename.replace('and', '&').replace('And', '&').replace('AND', '&').strip() + + constraints_parts = [f'titleTextConstraint: {{searchTerm: "{filename}"}}'] + + # Add release date constraint if search_year is provided + if not wide_search and search_year: + search_year_int = int(search_year) + start_year = search_year_int - 1 + end_year = search_year_int + 1 + constraints_parts.append(f'releaseDateConstraint: {{releaseDateRange: {{start: "{start_year}-01-01", end: "{end_year}-12-31"}}}}') + + if not wide_search and duration: + if isinstance(duration, int): + duration = str(duration) + start_duration = int(duration) - 10 + end_duration = int(duration) + 10 + constraints_parts.append(f'runtimeConstraint: {{runtimeRangeMinutes: {{min: {start_duration}, max: {end_duration}}}}}') + + constraints_string = ', '.join(constraints_parts) + + query = { + "query": f""" + {{ + advancedTitleSearch( + first: 10, + constraints: {{{constraints_string}}} + ) {{ + total + edges {{ + node {{ + title {{ + id + titleText {{ + text + }} + titleType {{ + text + }} + releaseYear {{ + year + }} + plot {{ + plotText {{ + plainText + }} + }} + }} + }} + }} + }} + }} + """ + } + + try: + async with httpx.AsyncClient() as client: + response = await client.post(url, json=query, headers={"Content-Type": "application/json"}, timeout=10) + response.raise_for_status() + data = response.json() + except Exception as e: + console.print(f"[red]IMDb GraphQL API error: {e}[/red]") + return 0 + + results = await safe_get(data, ["data", "advancedTitleSearch", "edges"], []) + search_results = results + + if debug: + console.print(f"[yellow]Found {len(results)} results...[/yellow]") + console.print(f"quickie: {quickie}, category: {category}, search_year: {search_year}") + return search_results + + if not search_results: + result = await run_imdb_search(filename, search_year, category, debug, attempted, duration, wide_search=False) + if result and len(result) > 0: + search_results = result + + if not search_results and secondary_title: + if debug: + console.print(f"[yellow]Trying IMDb with secondary title: {secondary_title}[/yellow]") + result = await run_imdb_search(secondary_title, search_year, category, debug, attempted, duration, wide_search=True) + if result and len(result) > 0: + search_results = result + + # remove 'the' from the beginning of the title if it exists + if not search_results: + try: + words = filename.split() + bad_words = ['the'] + words_lower = [word.lower() for word in words] + + if words_lower and words_lower[0] in bad_words: + words.pop(0) + words_lower.pop(0) + title = ' '.join(words) + if debug: + console.print(f"[bold yellow]Trying IMDb with the prefix removed: {title}[/bold yellow]") + result = await run_imdb_search(title, search_year, category, debug, attempted + 1, wide_search=False) + if result and len(result) > 0: + search_results = result + except Exception as e: + console.print(f"[bold red]Reduced name search error:[/bold red] {e}") + search_results = {"results": []} + + # relax the constraints + if not search_results: + if debug: + console.print("[yellow]No results found, trying with a wider search...[/yellow]") + try: + result = await run_imdb_search(filename, search_year, category, debug, attempted + 1, wide_search=True) + if result and len(result) > 0: + search_results = result + except Exception as e: + console.print(f"[red]Error during wide search: {e}[/red]") + + # Try parsed title (anitopy + guessit) + if not search_results: + try: + parsed = guessit(untouched_filename, {"excludes": ["country", "language"]}) + parsed_title = anitopy_parse(parsed['title'])['anime_title'] + if debug: + console.print(f"[bold yellow]Trying IMDB with parsed title: {parsed_title}[/bold yellow]") + result = await run_imdb_search(parsed_title, search_year, category, debug, attempted + 1, wide_search=True) + if result and len(result) > 0: + search_results = result + except Exception: + console.print("[bold red]Guessit failed parsing title, trying another method[/bold red]") + + # Try with less words in the title + if not search_results: + try: + words = filename.split() + extensions = ['mp4', 'mkv', 'avi', 'webm', 'mov', 'wmv'] + words_lower = [word.lower() for word in words] + + for ext in extensions: + if ext in words_lower: + ext_index = words_lower.index(ext) + words.pop(ext_index) + words_lower.pop(ext_index) + break + + if len(words) > 1: + reduced_title = ' '.join(words[:-1]) + if debug: + console.print(f"[bold yellow]Trying IMDB with reduced name: {reduced_title}[/bold yellow]") + result = await run_imdb_search(reduced_title, search_year, category, debug, attempted + 1, wide_search=True) + if result and len(result) > 0: + search_results = result + except Exception as e: + console.print(f"[bold red]Reduced name search error:[/bold red] {e}") + + # Try with even fewer words + if not search_results: + try: + words = filename.split() + extensions = ['mp4', 'mkv', 'avi', 'webm', 'mov', 'wmv'] + words_lower = [word.lower() for word in words] + + for ext in extensions: + if ext in words_lower: + ext_index = words_lower.index(ext) + words.pop(ext_index) + words_lower.pop(ext_index) + break + + if len(words) > 2: + further_reduced_title = ' '.join(words[:-2]) + if debug: + console.print(f"[bold yellow]Trying IMDB with further reduced name: {further_reduced_title}[/bold yellow]") + result = await run_imdb_search(further_reduced_title, search_year, category, debug, attempted + 1, wide_search=True) + if result and len(result) > 0: + search_results = result + except Exception as e: + console.print(f"[bold red]Further reduced name search error:[/bold red] {e}") + + if quickie: + if search_results: + first_result = search_results[0] + if debug: + console.print(f"[cyan]Quickie search result: {first_result}[/cyan]") + node = await safe_get(first_result, ["node"], {}) + title = await safe_get(node, ["title"], {}) + type_info = await safe_get(title, ["titleType"], {}) + year = await safe_get(title, ["releaseYear", "year"], None) + imdb_id = await safe_get(title, ["id"], "") + year_int = int(year) if year else None + search_year_int = int(search_year) if search_year else None + + type_matches = False + if type_info: + title_type = type_info.get("text", "").lower() + if category and category.lower() == "tv" and "tv series" in title_type: + type_matches = True + elif category and category.lower() == "movie" and "tv series" not in title_type: + type_matches = True + + if imdb_id and type_matches: + if year_int and search_year_int: + if year_int == search_year_int: + imdbID = int(imdb_id.replace('tt', '').strip()) + return imdbID + else: + if debug: + console.print(f"[yellow]Year mismatch: found {year_int}, expected {search_year_int}[/yellow]") + return 0 + else: + imdbID = int(imdb_id.replace('tt', '').strip()) + return imdbID + else: + if not imdb_id and debug: + console.print("[yellow]No IMDb ID found in quickie result[/yellow]") + if not type_matches and debug: + console.print(f"[yellow]Type mismatch: found {type_info.get('text', '')}, expected {category}[/yellow]") + imdbID = 0 + + return imdbID if imdbID else 0 + + else: + if len(search_results) == 1: + imdb_id = await safe_get(search_results[0], ["node", "title", "id"], "") + if imdb_id: + imdbID = int(imdb_id.replace('tt', '').strip()) + return imdbID + elif len(search_results) > 1: + # Calculate similarity for all results + results_with_similarity = [] + filename_norm = filename.lower().strip() + search_year_int = int(search_year) if search_year else 0 + + for r in search_results: + node = await safe_get(r, ["node"], {}) + title = await safe_get(node, ["title"], {}) + title_text = await safe_get(title, ["titleText", "text"], "") + result_year = await safe_get(title, ["releaseYear", "year"], 0) + + similarity = SequenceMatcher(None, filename_norm, title_text.lower().strip()).ratio() + + # Only boost similarity if titles are very similar (>= 0.99) AND years match + if similarity >= 0.99 and search_year_int > 0 and result_year > 0: + if result_year == search_year_int: + similarity += 0.1 # Full boost for exact year match + elif result_year == search_year_int - 1: + similarity += 0.05 # Half boost for -1 year + + results_with_similarity.append((r, similarity)) + + # Sort by similarity (highest first) + results_with_similarity.sort(key=lambda x: x[1], reverse=True) + + # Filter results: if we have high similarity matches (>= 0.90), hide low similarity ones (< 0.75) + best_similarity = results_with_similarity[0][1] + if best_similarity >= 0.90: + filtered_results_with_similarity = [ + (result, sim) for result, sim in results_with_similarity + if sim >= 0.75 + ] + results_with_similarity = filtered_results_with_similarity + + if debug: + console.print(f"[yellow]Filtered out low similarity results (< 0.70) since best match has {best_similarity:.2f} similarity[/yellow]") + + sorted_results = [r[0] for r in results_with_similarity] + + # Check if the best match is significantly better than others + best_similarity = results_with_similarity[0][1] + similarity_threshold = 0.85 + + if best_similarity >= similarity_threshold: + second_best = results_with_similarity[1][1] if len(results_with_similarity) > 1 else 0.0 + + if best_similarity - second_best >= 0.10: + if debug: + console.print(f"[green]Auto-selecting best match: {await safe_get(sorted_results[0], ['node', 'title', 'titleText', 'text'], '')} (similarity: {best_similarity:.2f})[/green]") + imdb_id = await safe_get(sorted_results[0], ["node", "title", "id"], "") + if imdb_id: + imdbID = int(imdb_id.replace('tt', '').strip()) + return imdbID + + if unattended: + imdb_id = await safe_get(sorted_results[0], ["node", "title", "id"], "") + if imdb_id: + imdbID = int(imdb_id.replace('tt', '').strip()) + if debug: + console.print(f"[green]Unattended mode: auto-selected IMDb ID {imdbID}[/green]") + return imdbID + + # Show sorted results to user + console.print("[bold yellow]Multiple IMDb results found. Please select the correct entry:[/bold yellow]") + + for idx, result in enumerate(sorted_results): + node = await safe_get(result, ["node"], {}) + title = await safe_get(node, ["title"], {}) + title_text = await safe_get(title, ["titleText", "text"], "") + year = await safe_get(title, ["releaseYear", "year"], None) + imdb_id = await safe_get(title, ["id"], "") + title_type = await safe_get(title, ["titleType", "text"], "") + plot = await safe_get(title, ["plot", "plotText", "plainText"], "") + similarity_score = results_with_similarity[idx][1] + + console.print(f"[cyan]{idx+1}.[/cyan] [bold]{title_text}[/bold] ({year}) [yellow]ID:[/yellow] {imdb_id} [yellow]Type:[/yellow] {title_type} [dim](similarity: {similarity_score:.2f})[/dim]") + if plot: + console.print(f"[green]Plot:[/green] {plot[:200]}{'...' if len(plot) > 200 else ''}") + console.print() + + if sorted_results: + selection = None + while True: + try: + selection = cli_ui.ask_string("Enter the number of the correct entry, 0 for none, or manual IMDb ID (tt1234567): ") + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + try: + # Check if it's a manual IMDb ID entry + if selection.lower().startswith('tt') and len(selection) >= 3: + try: + manual_imdb_id = selection.lower().replace('tt', '').strip() + if manual_imdb_id.isdigit(): + console.print(f"[green]Using manual IMDb ID: {selection}[/green]") + return int(manual_imdb_id) + else: + console.print("[bold red]Invalid IMDb ID format. Please try again.[/bold red]") + continue + except Exception as e: + console.print(f"[bold red]Error parsing IMDb ID: {e}. Please try again.[/bold red]") + continue + + # Handle numeric selection + selection_int = int(selection) + if 1 <= selection_int <= len(sorted_results): + selected = sorted_results[selection_int - 1] + imdb_id = await safe_get(selected, ["node", "title", "id"], "") + if imdb_id: + imdbID = int(imdb_id.replace('tt', '').strip()) + return imdbID + elif selection_int == 0: + console.print("[bold red]Skipping IMDb[/bold red]") + return 0 + else: + console.print("[bold red]Selection out of range. Please try again.[/bold red]") + except ValueError: + console.print("[bold red]Invalid input. Please enter a number or IMDb ID (tt1234567).[/bold red]") + + else: + try: + selection = cli_ui.ask_string("No results found. Please enter a manual IMDb ID (tt1234567) or 0 to skip: ") + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + if selection.lower().startswith('tt') and len(selection) >= 3: + try: + manual_imdb_id = selection.lower().replace('tt', '').strip() + if manual_imdb_id.isdigit(): + console.print(f"[green]Using manual IMDb ID: {selection}[/green]") + return int(manual_imdb_id) + else: + console.print("[bold red]Invalid IMDb ID format. Please try again.[/bold red]") + except Exception as e: + console.print(f"[bold red]Error parsing IMDb ID: {e}. Please try again.[/bold red]") + + return imdbID if imdbID else 0 + + +async def get_imdb_from_episode(imdb_id, debug=False): + if not imdb_id or imdb_id == 0: + return None + + if not str(imdb_id).startswith("tt"): + try: + imdb_id_int = int(imdb_id) + imdb_id = f"tt{imdb_id_int:07d}" + except Exception: + imdb_id = f"tt{str(imdb_id).zfill(7)}" + + query = { + "query": f""" + {{ + title(id: "{imdb_id}") {{ + id + titleText {{ text }} + series {{ + displayableEpisodeNumber {{ + displayableSeason {{ + id + season + text + }} + episodeNumber {{ + id + text + }} + }} + nextEpisode {{ + id + titleText {{ text }} + }} + previousEpisode {{ + id + titleText {{ text }} + }} + series {{ + id + titleText {{ text }} + }} + }} + }} + }} + """ + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + "https://api.graphql.imdb.com/", + json=query, + headers={"Content-Type": "application/json"}, + timeout=10 + ) + response.raise_for_status() + data = response.json() + except Exception as e: + if debug: + print(f"[red]IMDb API error: {e}[/red]") + return None + + title_data = await safe_get(data, ["data", "title"], {}) + if not title_data: + return None + + result = { + "id": await safe_get(title_data, ["id"]), + "title": await safe_get(title_data, ["titleText", "text"]), + "series": {}, + "next_episode": {}, + "previous_episode": {}, + } + + series_info = await safe_get(title_data, ["series"], {}) + if series_info: + displayable = await safe_get(series_info, ["displayableEpisodeNumber"], {}) + season_info = await safe_get(displayable, ["displayableSeason"], {}) + episode_info = await safe_get(displayable, ["episodeNumber"], {}) + result["series"]["season_id"] = await safe_get(season_info, ["id"]) + result["series"]["season"] = await safe_get(season_info, ["season"]) + result["series"]["season_text"] = await safe_get(season_info, ["text"]) + result["series"]["episode_id"] = await safe_get(episode_info, ["id"]) + result["series"]["episode_text"] = await safe_get(episode_info, ["text"]) + + # Next episode + next_ep = await safe_get(series_info, ["nextEpisode"], {}) + result["next_episode"]["id"] = await safe_get(next_ep, ["id"]) + result["next_episode"]["title"] = await safe_get(next_ep, ["titleText", "text"]) + + # Previous episode + prev_ep = await safe_get(series_info, ["previousEpisode"], {}) + result["previous_episode"]["id"] = await safe_get(prev_ep, ["id"]) + result["previous_episode"]["title"] = await safe_get(prev_ep, ["titleText", "text"]) + + # Series info + series_obj = await safe_get(series_info, ["series"], {}) + result["series"]["series_id"] = await safe_get(series_obj, ["id"]) + result["series"]["series_title"] = await safe_get(series_obj, ["titleText", "text"]) + + return result diff --git a/src/is_scene.py b/src/is_scene.py new file mode 100644 index 000000000..522feb6c2 --- /dev/null +++ b/src/is_scene.py @@ -0,0 +1,183 @@ +import os +import re +import requests +import urllib.parse +from bs4 import BeautifulSoup +from data.config import config +from src.console import console + + +async def is_scene(video, meta, imdb=None, lower=False): + scene = False + is_all_lowercase = False + base = os.path.basename(video) + match = re.match(r"^(.+)\.[a-zA-Z0-9]{3}$", os.path.basename(video)) + + if match and (not meta['is_disc'] or meta['keep_folder']): + base = match.group(1) + is_all_lowercase = base.islower() + base = urllib.parse.quote(base) + if 'scene' not in meta and not lower and not meta.get('emby_debug', False): + url = f"https://api.srrdb.com/v1/search/r:{base}" + if meta['debug']: + console.print("Using SRRDB url", url) + try: + response = requests.get(url, timeout=30) + response_json = response.json() + if meta['debug']: + console.print(response_json) + + if int(response_json.get('resultsCount', 0)) > 0: + first_result = response_json['results'][0] + meta['scene_name'] = first_result['release'] + video = f"{first_result['release']}.mkv" + scene = True + if is_all_lowercase and not meta.get('tag'): + meta['we_need_tag'] = True + if first_result.get('imdbId'): + imdb_str = first_result['imdbId'] + imdb = int(imdb_str) if imdb_str.isdigit() else 0 + + # NFO Download Handling + if not meta.get('nfo') and not meta.get('emby', False): + if first_result.get("hasNFO") == "yes": + try: + release = first_result['release'] + release_lower = release.lower() + + release_details_url = f"https://api.srrdb.com/v1/details/{release}" + release_details_response = requests.get(release_details_url, timeout=30) + if release_details_response.status_code == 200: + try: + release_details_dict = release_details_response.json() + for file in release_details_dict['files']: + if file['name'].endswith('.nfo'): + release_lower = os.path.splitext(file['name'])[0] + except (KeyError, ValueError): + pass + + nfo_url = f"https://www.srrdb.com/download/file/{release}/{release_lower}.nfo" + + # Define path and create directory + save_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid']) + os.makedirs(save_path, exist_ok=True) + nfo_file_path = os.path.join(save_path, f"{release_lower}.nfo") + + # Download the NFO file + nfo_response = requests.get(nfo_url, timeout=30) + if nfo_response.status_code == 200: + with open(nfo_file_path, 'wb') as f: + f.write(nfo_response.content) + meta['nfo'] = True + meta['auto_nfo'] = True + if meta['debug']: + console.print(f"[green]NFO downloaded to {nfo_file_path}") + else: + console.print("[yellow]NFO file not available for download.") + except Exception as e: + console.print("[yellow]Failed to download NFO file:", e) + else: + if meta['debug']: + console.print("[yellow]SRRDB: No match found") + + except Exception as e: + console.print(f"[yellow]SRRDB: No match found, or request has timed out: {e}") + + elif not scene and lower and not meta.get('emby_debug', False): + release_name = None + name = meta.get('filename', None).replace(" ", ".") + tag = meta.get('tag', None).replace("-", "") + url = f"https://api.srrdb.com/v1/search/start:{name}/group:{tag}" + if meta['debug']: + console.print("Using SRRDB url", url) + + try: + response = requests.get(url, timeout=10) + response_json = response.json() + + if int(response_json.get('resultsCount', 0)) > 0: + first_result = response_json['results'][0] + imdb_str = first_result['imdbId'] + if imdb_str and imdb_str == str(meta.get('imdb_id')).zfill(7) and meta.get('imdb_id') != 0: + meta['scene'] = True + release_name = first_result['release'] + + # NFO Download Handling + if not meta.get('nfo'): + if first_result.get("hasNFO") == "yes": + try: + release = first_result['release'] + release_lower = release.lower() + nfo_url = f"https://www.srrdb.com/download/file/{release}/{base}.nfo" + + # Define path and create directory + save_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid']) + os.makedirs(save_path, exist_ok=True) + nfo_file_path = os.path.join(save_path, f"{release_lower}.nfo") + + # Download the NFO file + nfo_response = requests.get(nfo_url, timeout=30) + if nfo_response.status_code == 200: + with open(nfo_file_path, 'wb') as f: + f.write(nfo_response.content) + meta['nfo'] = True + meta['auto_nfo'] = True + console.print(f"[green]NFO downloaded to {nfo_file_path}") + else: + console.print("[yellow]NFO file not available for download.") + except Exception as e: + console.print("[yellow]Failed to download NFO file:", e) + + return release_name + + except Exception as e: + console.print(f"[yellow]SRRDB search failed: {e}") + return None + + check_predb = config['DEFAULT'].get('check_predb', False) + if not scene and check_predb and not meta.get('emby_debug', False): + if meta['debug']: + console.print("[yellow]SRRDB: No scene match found, checking predb") + scene = await predb_check(meta, video) + + return video, scene, imdb + + +async def predb_check(meta, video): + url = f"https://predb.pw/search.php?search={urllib.parse.quote(os.path.basename(video))}" + if meta['debug']: + console.print("Using predb url", url) + try: + response = requests.get(url, timeout=10) + if response.status_code == 200: + soup = BeautifulSoup(response.text, "lxml") + found = False + video_base = os.path.basename(video).lower() + for row in soup.select('table.zebra-striped tbody tr'): + tds = row.find_all('td') + if len(tds) >= 3: + # The 3rd contains the release name link + release_a = tds[2].find('a', title=True) + if release_a: + release_name = release_a['title'].strip().lower() + if meta['debug']: + console.print(f"[yellow]Predb: Checking {release_name} against {video_base}") + if release_name == video_base: + found = True + meta['scene_name'] = release_a['title'].strip() + console.print("[green]Predb: Match found") + # The 4th contains the group + if len(tds) >= 4: + group_a = tds[3].find('a') + if group_a: + meta['tag'] = group_a.text.strip() + return True + if not found: + console.print("[yellow]Predb: No match found") + return False + else: + console.print(f"[red]Predb: Error {response.status_code} while checking") + return False + except requests.RequestException as e: + console.print(f"[red]Predb: Request failed: {e}") + return False diff --git a/src/languages.py b/src/languages.py new file mode 100644 index 000000000..a35751cee --- /dev/null +++ b/src/languages.py @@ -0,0 +1,458 @@ +import aiofiles +import cli_ui +import langcodes +import os +import re +import sys + +from src.cleanup import cleanup, reset_terminal +from src.console import console + + +async def parse_blu_ray(meta): + try: + bd_summary_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt" + if not os.path.exists(bd_summary_file): + console.print(f"[yellow]BD_SUMMARY_00.txt not found at {bd_summary_file}[/yellow]") + return {} + + async with aiofiles.open(bd_summary_file, 'r', encoding='utf-8') as f: + content = await f.read() + except Exception as e: + console.print(f"[red]Error reading BD_SUMMARY file: {e}[/red]") + return {} + + parsed_data = { + 'disc_info': {}, + 'playlist_info': {}, + 'video': {}, + 'audio': [], + 'subtitles': [] + } + + lines = content.strip().split('\n') + + for line in lines: + line = line.strip() + if not line: + continue + + if ':' in line: + key, value = line.split(':', 1) + key = key.strip() + value = value.strip() + + if key in ['Disc Title', 'Disc Label', 'Disc Size', 'Protection']: + parsed_data['disc_info'][key.lower().replace(' ', '_')] = value + + elif key in ['Playlist', 'Size', 'Length', 'Total Bitrate']: + parsed_data['playlist_info'][key.lower().replace(' ', '_')] = value + + elif key == 'Video': + video_parts = [part.strip() for part in value.split('/')] + if len(video_parts) >= 6: + parsed_data['video'] = { + 'format': video_parts[0], + 'bitrate': video_parts[1], + 'resolution': video_parts[2], + 'framerate': video_parts[3], + 'aspect_ratio': video_parts[4], + 'profile': video_parts[5] + } + else: + parsed_data['video']['format'] = value + + elif key == 'Audio' or (key.startswith('*') and 'Audio' in key): + is_commentary = key.startswith('*') + audio_parts = [part.strip() for part in value.split('/')] + + audio_track = { + 'is_commentary': is_commentary + } + + if len(audio_parts) >= 1: + audio_track['language'] = audio_parts[0] + if len(audio_parts) >= 2: + audio_track['format'] = audio_parts[1] + if len(audio_parts) >= 3: + audio_track['channels'] = audio_parts[2] + if len(audio_parts) >= 4: + audio_track['sample_rate'] = audio_parts[3] + if len(audio_parts) >= 5: + bitrate_str = audio_parts[4].strip() + bitrate_match = re.search(r'(\d+)\s*kbps', bitrate_str) + if bitrate_match: + audio_track['bitrate_num'] = int(bitrate_match.group(1)) + audio_track['bitrate'] = bitrate_str + if len(audio_parts) >= 6: + audio_track['bit_depth'] = audio_parts[5].split('(')[0].strip() + + parsed_data['audio'].append(audio_track) + + elif key == 'Subtitle' or (key.startswith('*') and 'Subtitle' in key): + is_commentary = key.startswith('*') + subtitle_parts = [part.strip() for part in value.split('/')] + + subtitle_track = { + 'is_commentary': is_commentary + } + + if len(subtitle_parts) >= 1: + subtitle_track['language'] = subtitle_parts[0] + if len(subtitle_parts) >= 2: + subtitle_track['bitrate'] = subtitle_parts[1] + + parsed_data['subtitles'].append(subtitle_track) + + return parsed_data + + +async def parsed_mediainfo(meta): + try: + mediainfo_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt" + if os.path.exists(mediainfo_file): + async with aiofiles.open(mediainfo_file, 'r', encoding='utf-8') as f: + mediainfo_content = await f.read() + except Exception as e: + console.print(f"[red]Error reading MEDIAINFO file: {e}[/red]") + return {} + + parsed_data = { + 'general': {}, + 'video': [], + 'audio': [], + 'text': [] + } + + current_section = None + current_track = {} + + lines = mediainfo_content.strip().split('\n') + + section_header_re = re.compile(r'^(General|Video|Audio|Text|Menu)(?:\s*#\d+)?$', re.IGNORECASE) + + for line in lines: + line = line.strip() + if not line: + continue + + section_match = section_header_re.match(line) + if section_match: + if current_section and current_track: + if current_section in ['video', 'audio', 'text']: + parsed_data[current_section].append(current_track) + elif current_section == 'general': + parsed_data['general'] = current_track + + current_section = section_match.group(1).lower() + current_track = {} + continue + + if ':' in line and current_section: + key, value = line.split(':', 1) + key = key.strip().lower() + value = value.strip() + + if current_section == 'video': + if key in ['format', 'duration', 'bit rate', 'encoding settings', 'title']: + current_track[key.replace(' ', '_')] = value + elif current_section == 'audio': + if key in ['format', 'duration', 'bit rate', 'language', 'commercial name', 'channel', 'channel (s)', 'title']: + current_track[key.replace(' ', '_')] = value + elif current_section == 'text': + if key in ['format', 'duration', 'bit rate', 'language', 'title']: + current_track[key.replace(' ', '_')] = value + elif current_section == 'general': + current_track[key.replace(' ', '_')] = value + + if current_section and current_track: + if current_section in ['video', 'audio', 'text']: + parsed_data[current_section].append(current_track) + elif current_section == 'general': + parsed_data['general'] = current_track + + return parsed_data + + +async def process_desc_language(meta, desc=None, tracker=None): + if 'language_checked' not in meta: + meta['language_checked'] = False + if 'tracker_status' not in meta: + meta['tracker_status'] = {} + if tracker not in meta['tracker_status']: + meta['tracker_status'][tracker] = {} + if 'unattended_audio_skip' not in meta: + meta['unattended_audio_skip'] = False + if 'unattended_subtitle_skip' not in meta: + meta['unattended_subtitle_skip'] = False + if 'no_subs' not in meta: + meta['no_subs'] = False + if 'write_hc_languages' not in meta: + meta['write_hc_languages'] = False + if 'write_audio_languages' not in meta: + meta['write_audio_languages'] = False + if 'write_subtitle_languages' not in meta: + meta['write_subtitle_languages'] = False + if 'write_hc_languages' not in meta: + meta['write_hc_languages'] = False + if not meta['is_disc'] == "BDMV": + try: + parsed_info = await parsed_mediainfo(meta) + audio_languages = [] + subtitle_languages = [] + if 'write_audio_languages' not in meta: + meta['write_audio_languages'] = False + if 'write_subtitle_languages' not in meta: + meta['write_subtitle_languages'] = False + if meta.get('audio_languages'): + audio_languages = meta['audio_languages'] + else: + meta['audio_languages'] = [] + if meta.get('subtitle_languages'): + subtitle_languages = meta['subtitle_languages'] + else: + meta['subtitle_languages'] = [] + if not audio_languages or not subtitle_languages: + if not meta.get('unattended_audio_skip', False) and (not audio_languages or audio_languages is None): + found_any_language = False + tracks_without_language = [] + + for track_index, audio_track in enumerate(parsed_info.get('audio', []), 1): + language_found = None + + # Skip commentary tracks + if "title" in audio_track and "commentary" in audio_track['title'].lower(): + if meta['debug']: + console.print(f"Skipping commentary track: {audio_track['title']}") + continue + + if 'language' in audio_track: + language_found = audio_track['language'] + + if not language_found and 'title' in audio_track: + if meta['debug']: + console.print(f"Attempting to extract language from title: {audio_track['title']}") + title_language = extract_language_from_title(audio_track['title']) + if title_language: + language_found = title_language + console.print(f"Extracted language: {title_language}") + + if language_found: + meta['audio_languages'].append(language_found) + found_any_language = True + else: + + track_info = f"Track #{track_index}" + if 'title' in audio_track: + track_info += f" (Title: {audio_track['title']})" + tracks_without_language.append(track_info) + + if not found_any_language: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print("No audio language/s found for the following tracks:") + for track_info in tracks_without_language: + console.print(f" - {track_info}") + console.print("You must enter (comma-separated) languages") + try: + audio_lang = cli_ui.ask_string('for all audio tracks, eg: English, Spanish:') + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + if audio_lang: + audio_languages.extend([lang.strip() for lang in audio_lang.split(',')]) + meta['audio_languages'] = audio_languages + meta['write_audio_languages'] = True + else: + meta['audio_languages'] = None + meta['unattended_audio_skip'] = True + meta['tracker_status'][tracker]['skip_upload'] = True + else: + meta['unattended_audio_skip'] = True + meta['tracker_status'][tracker]['skip_upload'] = True + if meta['debug']: + meta['audio_languages'] = ['English, Portuguese'] + + if meta['audio_languages']: + meta['audio_languages'] = [lang.split()[0] for lang in meta['audio_languages']] + + if (not meta.get('unattended_subtitle_skip', False) or not meta.get('unattended_audio_skip', False)) and (not subtitle_languages or subtitle_languages is None): + if 'text' in parsed_info: + tracks_without_language = [] + + for track_index, text_track in enumerate(parsed_info.get('text', []), 1): + if 'language' not in text_track: + track_info = f"Track #{track_index}" + if 'title' in text_track: + track_info += f" (Title: {text_track['title']})" + tracks_without_language.append(track_info) + else: + meta['subtitle_languages'].append(text_track['language']) + + if tracks_without_language: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print("No subtitle language/s found for the following tracks:") + for track_info in tracks_without_language: + console.print(f" - {track_info}") + console.print("You must enter (comma-separated) languages") + try: + subtitle_lang = cli_ui.ask_string('for all subtitle tracks, eg: English, Spanish:') + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + if subtitle_lang: + subtitle_languages.extend([lang.strip() for lang in subtitle_lang.split(',')]) + meta['subtitle_languages'] = subtitle_languages + meta['write_subtitle_languages'] = True + else: + meta['subtitle_languages'] = None + meta['unattended_subtitle_skip'] = True + meta['tracker_status'][tracker]['skip_upload'] = True + else: + meta['unattended_subtitle_skip'] = True + meta['tracker_status'][tracker]['skip_upload'] = True + if meta['debug']: + meta['subtitle_languages'] = ['English, Portuguese'] + + if meta['subtitle_languages']: + meta['subtitle_languages'] = [lang.split()[0] for lang in meta['subtitle_languages']] + + if meta.get('hardcoded-subs', False): + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + try: + hc_lang = cli_ui.ask_string("What language/s are the hardcoded subtitles?") + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + if hc_lang: + meta['subtitle_languages'] = [hc_lang] + meta['write_hc_languages'] = True + else: + meta['subtitle_languages'] = None + meta['unattended_subtitle_skip'] = True + meta['tracker_status'][tracker]['skip_upload'] = True + else: + meta['subtitle_languages'] = "English" + meta['write_hc_languages'] = True + if 'text' not in parsed_info and not meta.get('hardcoded-subs', False): + meta['no_subs'] = True + + except Exception as e: + console.print(f"[red]Error processing mediainfo languages: {e}[/red]") + + meta['language_checked'] = True + return desc if desc is not None else None + + elif meta['is_disc'] == "BDMV": + if "language_checked" not in meta: + meta['language_checked'] = False + if 'bluray_audio_skip' not in meta: + meta['bluray_audio_skip'] = False + audio_languages = [] + if meta.get('audio_languages'): + audio_languages = meta['audio_languages'] + else: + meta['audio_languages'] = [] + if meta.get('subtitle_languages'): + subtitle_languages = meta['subtitle_languages'] + else: + meta['subtitle_languages'] = [] + try: + bluray = await parse_blu_ray(meta) + audio_tracks = bluray.get("audio", []) + commentary_tracks = [track for track in audio_tracks if track.get("is_commentary")] + if commentary_tracks: + for track in commentary_tracks: + if meta['debug']: + console.print(f"Skipping commentary track: {track}") + audio_tracks.remove(track) + audio_languages = {track.get("language", "") for track in audio_tracks if "language" in track} + for track in audio_tracks: + bitrate_str = track.get("bitrate", "") + bitrate_num = None + if bitrate_str: + match = re.search(r'([\d.]+)\s*([kM]?b(?:ps|/s))', bitrate_str.replace(',', ''), re.IGNORECASE) + if match: + value = float(match.group(1)) + unit = match.group(2).lower() + if unit in ['mbps', 'mb/s']: + bitrate_num = int(value * 1000) + elif unit in ['kbps', 'kb/s']: + bitrate_num = int(value) + else: + bitrate_num = int(value) + + lang = track.get("language", "") + if bitrate_num is not None and bitrate_num < 258: + if lang and lang in audio_languages and len(lang) > 1 and not meta['bluray_audio_skip']: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print(f"Audio track '{lang}' has a bitrate of {bitrate_num} kbps. Probably commentary and should be removed.") + try: + if cli_ui.ask_yes_no(f"Remove '{lang}' from audio languages?", default=True): + audio_languages.discard(lang) if isinstance(audio_languages, set) else audio_languages.remove(lang) + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + else: + audio_languages.discard(lang) if isinstance(audio_languages, set) else audio_languages.remove(lang) + meta['bluray_audio_skip'] = True + + subtitle_tracks = bluray.get("subtitles", []) + sub_commentary_tracks = [track for track in subtitle_tracks if track.get("is_commentary")] + if sub_commentary_tracks: + for track in sub_commentary_tracks: + if meta['debug']: + console.print(f"Skipping commentary subtitle track: {track}") + subtitle_tracks.remove(track) + if subtitle_tracks and isinstance(subtitle_tracks[0], dict): + subtitle_languages = {track.get("language", "") for track in subtitle_tracks if "language" in track} + else: + subtitle_languages = set(subtitle_tracks) + if subtitle_languages: + meta['subtitle_languages'] = list(subtitle_languages) + + meta['audio_languages'] = list(audio_languages) + except Exception as e: + console.print(f"[red]Error processing BDInfo languages: {e}[/red]") + + meta['language_checked'] = True + return desc if desc is not None else None + + else: + meta['language_checked'] = True + return desc if desc is not None else None + + +async def has_english_language(languages): + """Check if any language in the list contains 'english'""" + if isinstance(languages, str): + languages = [languages] + if not languages: + return False + return any('english' in lang.lower() for lang in languages) + + +def extract_language_from_title(title): + """Extract language from title field using langcodes library""" + if not title: + return None + + title_lower = title.lower() + words = re.findall(r'\b[a-zA-Z]+\b', title_lower) + + for word in words: + try: + lang = langcodes.find(word) + if lang and lang.is_valid(): + return lang.display_name() + except (langcodes.LanguageTagError, LookupError): + continue + + return None diff --git a/src/manualpackage.py b/src/manualpackage.py new file mode 100644 index 000000000..8d179cef8 --- /dev/null +++ b/src/manualpackage.py @@ -0,0 +1,93 @@ +import shutil +import requests +import os +import json +import urllib.parse +import re +from torf import Torrent +import glob +from src.console import console +from src.uploadscreens import upload_screens +from data.config import config + + +async def package(meta): + if meta['tag'] == "": + tag = "" + else: + tag = f" / {meta['tag'][1:]}" + if meta['is_disc'] == "DVD": + res = meta['source'] + else: + res = meta['resolution'] + + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/GENERIC_INFO.txt", 'w', encoding="utf-8") as generic: + generic.write(f"Name: {meta['name']}\n\n") + generic.write(f"Overview: {meta['overview']}\n\n") + generic.write(f"{res} / {meta['type']}{tag}\n\n") + generic.write(f"Category: {meta['category']}\n") + generic.write(f"TMDB: https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}\n") + if meta['imdb_id'] != 0: + generic.write(f"IMDb: https://www.imdb.com/title/tt{meta['imdb_id']}\n") + if meta['tvdb_id'] != 0: + generic.write(f"TVDB: https://www.thetvdb.com/?id={meta['tvdb_id']}&tab=series\n") + if "tvmaze_id" in meta and meta['tvmaze_id'] != 0: + generic.write(f"TVMaze: https://www.tvmaze.com/shows/{meta['tvmaze_id']}\n") + poster_img = f"{meta['base_dir']}/tmp/{meta['uuid']}/POSTER.png" + if meta.get('poster', None) not in ['', None] and not os.path.exists(poster_img): + if meta.get('rehosted_poster', None) is None: + r = requests.get(meta['poster'], stream=True) + if r.status_code == 200: + console.print("[bold yellow]Rehosting Poster") + r.raw.decode_content = True + with open(poster_img, 'wb') as f: + shutil.copyfileobj(r.raw, f) + if not meta.get('skip_imghost_upload', False): + poster, dummy = await upload_screens(meta, 1, 1, 0, 1, [poster_img], {}) + poster = poster[0] + generic.write(f"TMDB Poster: {poster.get('raw_url', poster.get('img_url'))}\n") + meta['rehosted_poster'] = poster.get('raw_url', poster.get('img_url')) + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as metafile: + json.dump(meta, metafile, indent=4) + metafile.close() + else: + console.print("[bold yellow]Poster could not be retrieved") + elif os.path.exists(poster_img) and meta.get('rehosted_poster') is not None: + generic.write(f"TMDB Poster: {meta.get('rehosted_poster')}\n") + if len(meta['image_list']) > 0: + generic.write("\nImage Webpage:\n") + for each in meta['image_list']: + generic.write(f"{each['web_url']}\n") + generic.write("\nThumbnail Image:\n") + for each in meta['image_list']: + generic.write(f"{each['img_url']}\n") + title = re.sub(r"[^0-9a-zA-Z\[\\]]+", "", meta['title']) + archive = f"{meta['base_dir']}/tmp/{meta['uuid']}/{title}" + torrent_files = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", "*.torrent") + if isinstance(torrent_files, list) and len(torrent_files) > 1: + for each in torrent_files: + if not each.startswith(('BASE', '[RAND')): + os.remove(os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/{each}")) + try: + if os.path.exists(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent"): + base_torrent = Torrent.read(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent") + manual_name = re.sub(r"[^0-9a-zA-Z\[\]\'\-]+", ".", os.path.basename(meta['path'])) + Torrent.copy(base_torrent).write(f"{meta['base_dir']}/tmp/{meta['uuid']}/{manual_name}.torrent", overwrite=True) + # shutil.copy(os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent"), os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['name'].replace(' ', '.')}.torrent").replace(' ', '.')) + filebrowser = config['TRACKERS'].get('MANUAL', {}).get('filebrowser', None) + shutil.make_archive(archive, 'tar', f"{meta['base_dir']}/tmp/{meta['uuid']}") + if filebrowser is not None: + url = '/'.join(s.strip('/') for s in (filebrowser, f"/tmp/{meta['uuid']}")) + url = urllib.parse.quote(url, safe="https://") + else: + files = { + "files[]": (f"{meta['title']}.tar", open(f"{archive}.tar", 'rb')) + } + response = requests.post("https://uguu.se/upload.php", files=files).json() + if meta['debug']: + console.print(f"[cyan]{response}") + url = response['files'][0]['url'] + return url + except Exception: + return False + return diff --git a/src/metadata_searching.py b/src/metadata_searching.py new file mode 100644 index 000000000..7ded49cd6 --- /dev/null +++ b/src/metadata_searching.py @@ -0,0 +1,841 @@ +import re +import asyncio +from src.console import console +from src.tvmaze import search_tvmaze, get_tvmaze_episode_data +from src.imdb import get_imdb_info_api +from src.tmdb import tmdb_other_meta, get_tmdb_from_imdb, get_episode_details +from src.tvdb import get_tvdb_episode_data, get_tvdb_series_data, get_tvdb_series_episodes, get_tvdb_series, get_tvdb_specific_episode_data + + +async def all_ids(meta, tvdb_api=None, tvdb_token=None): + # Create a list of all tasks to run in parallel + all_tasks = [ + # Core metadata tasks + tmdb_other_meta( + tmdb_id=meta['tmdb_id'], + path=meta.get('path'), + search_year=meta.get('search_year'), + category=meta.get('category'), + imdb_id=meta.get('imdb_id', 0), + manual_language=meta.get('manual_language'), + anime=meta.get('anime', False), + mal_manual=meta.get('mal_manual'), + aka=meta.get('aka', ''), + original_language=meta.get('original_language'), + poster=meta.get('poster'), + debug=meta.get('debug', False), + mode=meta.get('mode', 'cli'), + tvdb_id=meta.get('tvdb_id', 0) + ), + get_imdb_info_api( + meta['imdb_id'], + manual_language=meta.get('manual_language'), + debug=meta.get('debug', False) + ) + ] + + # Add episode-specific tasks if this is a TV show with episodes + if (meta['category'] == 'TV' and not meta.get('tv_pack', False) and + 'season_int' in meta and 'episode_int' in meta and meta.get('episode_int') != 0): + + # Add TVDb task if we have credentials + if tvdb_api and tvdb_token: + all_tasks.append( + get_tvdb_episode_data( + meta['base_dir'], + tvdb_token, + meta.get('tvdb_id'), + meta.get('season_int'), + meta.get('episode_int'), + api_key=tvdb_api, + debug=meta.get('debug', False) + ) + ) + + # Add TVMaze episode details task + all_tasks.append( + get_tvmaze_episode_data( + meta.get('tvmaze_id'), + meta.get('season_int'), + meta.get('episode_int') + ) + ) + # TMDb last + all_tasks.append( + get_episode_details( + meta.get('tmdb_id'), + meta.get('season_int'), + meta.get('episode_int'), + debug=meta.get('debug', False) + ) + ) + elif meta.get('category') == 'TV' and meta.get('tv_pack', False): + if tvdb_api and tvdb_token: + all_tasks.append( + get_tvdb_series_data( + meta['base_dir'], + tvdb_token, + meta.get('tvdb_id'), + api_key=tvdb_api, + debug=meta.get('debug', False) + ) + ) + + # Execute all tasks in parallel + try: + results = await asyncio.gather(*all_tasks, return_exceptions=True) + except Exception as e: + console.print(f"[red]Error occurred while gathering tasks: {e}[/red]") + return meta + + # Process core metadata results + try: + tmdb_metadata, imdb_info = results[0:2] + except Exception as e: + console.print(f"[red]Error occurred while processing core metadata: {e}[/red]") + pass + result_index = 2 # Start processing episode data from this index + + # Process TMDB metadata + if not isinstance(tmdb_metadata, Exception) and tmdb_metadata: + meta.update(tmdb_metadata) + else: + console.print("[yellow]Warning: Could not get TMDB metadata") + + # Process IMDB info + if isinstance(imdb_info, dict): + meta['imdb_info'] = imdb_info + meta['tv_year'] = imdb_info.get('tv_year', None) + + elif isinstance(imdb_info, Exception): + console.print(f"[red]IMDb API call failed: {imdb_info}[/red]") + meta['imdb_info'] = meta.get('imdb_info', {}) # Keep previous IMDb info if it exists + else: + console.print("[red]Unexpected IMDb response, setting imdb_info to empty.[/red]") + meta['imdb_info'] = {} + + # Process episode data if this is a TV show + if meta['category'] == 'TV' and not meta.get('tv_pack', False) and meta.get('episode_int', 0) != 0: + # Process TVDb episode data (if included) + if tvdb_api and tvdb_token: + tvdb_episode_data = results[result_index] + result_index += 1 + + if tvdb_episode_data and not isinstance(tvdb_episode_data, Exception): + meta['tvdb_episode_data'] = tvdb_episode_data + meta['we_checked_tvdb'] = True + + # Process episode name + if meta['tvdb_episode_data'].get('episode_name'): + episode_name = meta['tvdb_episode_data'].get('episode_name') + if episode_name and isinstance(episode_name, str) and episode_name.strip(): + if 'episode' in episode_name.lower(): + meta['auto_episode_title'] = None + meta['tvdb_episode_title'] = None + else: + meta['tvdb_episode_title'] = episode_name.strip() + meta['auto_episode_title'] = episode_name.strip() + else: + meta['auto_episode_title'] = None + + # Process overview + if meta['tvdb_episode_data'].get('overview'): + overview = meta['tvdb_episode_data'].get('overview') + if overview and isinstance(overview, str) and overview.strip(): + meta['overview_meta'] = overview.strip() + else: + meta['overview_meta'] = None + else: + meta['overview_meta'] = None + + # Process season and episode numbers + if meta['tvdb_episode_data'].get('season_name'): + meta['tvdb_season_name'] = meta['tvdb_episode_data'].get('season_name') + + if meta['tvdb_episode_data'].get('season_number'): + meta['tvdb_season_number'] = meta['tvdb_episode_data'].get('season_number') + + if meta['tvdb_episode_data'].get('episode_number'): + meta['tvdb_episode_number'] = meta['tvdb_episode_data'].get('episode_number') + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('series_name'): + year = meta['tvdb_episode_data'].get('series_name') + year_match = re.search(r'\b(19\d\d|20[0-3]\d)\b', year) + if year_match: + meta['search_year'] = year_match.group(0) + else: + meta['search_year'] = "" + + elif isinstance(tvdb_episode_data, Exception): + console.print(f"[yellow]TVDb episode data retrieval failed: {tvdb_episode_data}") + + # Process TVMaze episode data + tvmaze_episode_data = results[result_index] + result_index += 1 + + if not isinstance(tvmaze_episode_data, Exception) and tvmaze_episode_data: + meta['tvmaze_episode_data'] = tvmaze_episode_data + + # Only set title if not already set + if meta.get('auto_episode_title') is None and tvmaze_episode_data.get('name') is not None: + if 'episode' in tvmaze_episode_data.get('name', '').lower(): + meta['auto_episode_title'] = None + else: + meta['auto_episode_title'] = tvmaze_episode_data['name'] + + # Only set overview if not already set + if meta.get('overview_meta') is None and tvmaze_episode_data.get('overview') is not None: + meta['overview_meta'] = tvmaze_episode_data.get('overview', None) + meta['we_asked_tvmaze'] = True + elif isinstance(tvmaze_episode_data, Exception): + console.print(f"[yellow]TVMaze episode data retrieval failed: {tvmaze_episode_data}") + + # Process TMDb episode data + tmdb_episode_data = results[result_index] + result_index += 1 + + if not isinstance(tmdb_episode_data, Exception) and tmdb_episode_data: + meta['tmdb_episode_data'] = tmdb_episode_data + meta['we_checked_tmdb'] = True + + # Only set title if not already set + if meta.get('auto_episode_title') is None and tmdb_episode_data.get('name') is not None: + if 'episode' in tmdb_episode_data.get('name', '').lower(): + meta['auto_episode_title'] = None + else: + meta['auto_episode_title'] = tmdb_episode_data['name'] + + # Only set overview if not already set + if meta.get('overview_meta') is None and tmdb_episode_data.get('overview') is not None: + meta['overview_meta'] = tmdb_episode_data.get('overview', None) + elif isinstance(tmdb_episode_data, Exception): + console.print(f"[yellow]TMDb episode data retrieval failed: {tmdb_episode_data}") + + elif meta.get('category') == 'TV' and meta.get('tv_pack', False): + if tvdb_api and tvdb_token: + # Process TVDb series data + tvdb_series_data = results[result_index] + result_index += 1 + + if tvdb_series_data and not isinstance(tvdb_series_data, Exception): + meta['tvdb_series_name'] = tvdb_series_data + meta['we_checked_tvdb'] = True + + elif isinstance(tvdb_series_data, Exception): + console.print(f"[yellow]TVDb series data retrieval failed: {tvdb_series_data}") + return meta + + +async def imdb_tmdb_tvdb(meta, filename, tvdb_api=None, tvdb_token=None): + if meta['debug']: + console.print("[yellow]IMDb, TMDb, and TVDb IDs are all present[/yellow]") + # Core metadata tasks that run in parallel + tasks = [ + tmdb_other_meta( + tmdb_id=meta['tmdb_id'], + path=meta.get('path'), + search_year=meta.get('search_year'), + category=meta.get('category'), + imdb_id=meta.get('imdb_id', 0), + manual_language=meta.get('manual_language'), + anime=meta.get('anime', False), + mal_manual=meta.get('mal_manual'), + aka=meta.get('aka', ''), + original_language=meta.get('original_language'), + poster=meta.get('poster'), + debug=meta.get('debug', False), + mode=meta.get('mode', 'cli'), + tvdb_id=meta.get('tvdb_id', 0) + ), + + get_imdb_info_api( + meta['imdb_id'], + manual_language=meta.get('manual_language'), + debug=meta.get('debug', False) + ), + + search_tvmaze( + filename, meta['search_year'], meta.get('imdb_id', 0), meta.get('tvdb_id', 0), + manual_date=meta.get('manual_date'), + tvmaze_manual=meta.get('tvmaze_manual'), + debug=meta.get('debug', False), + return_full_tuple=False + ) if meta.get('category') == 'TV' else None + ] + + # Filter out None tasks + tasks = [task for task in tasks if task is not None] + + if (meta.get('category') == 'TV' and not meta.get('tv_pack', False) and + 'season_int' in meta and 'episode_int' in meta and meta.get('episode_int') != 0): + + if tvdb_api and tvdb_token: + tvdb_task = get_tvdb_episode_data( + meta['base_dir'], tvdb_token, meta.get('tvdb_id'), + meta.get('season_int'), meta.get('episode_int'), + api_key=tvdb_api, debug=meta.get('debug', False) + ) + tasks.append(tvdb_task) + + tasks.append( + get_episode_details( + meta.get('tmdb_id'), meta.get('season_int'), meta.get('episode_int'), + debug=meta.get('debug', False) + ) + ) + + elif meta.get('category') == 'TV' and meta.get('tv_pack', False) and tvdb_api and tvdb_token: + tvdb_series_task = get_tvdb_series_data( + meta['base_dir'], tvdb_token, meta.get('tvdb_id'), + api_key=tvdb_api, debug=meta.get('debug', False) + ) + tasks.append(tvdb_series_task) + + # Execute all tasks in parallel + results = await asyncio.gather(*tasks, return_exceptions=True) + result_index = 0 + + # Process core metadata (always in first positions) + if len(results) > result_index: + tmdb_metadata = results[result_index] + result_index += 1 + if not isinstance(tmdb_metadata, Exception) and tmdb_metadata: + meta.update(tmdb_metadata) + else: + console.print(f"[yellow]TMDb metadata retrieval failed: {tmdb_metadata}[/yellow]") + + if len(results) > result_index: + imdb_info = results[result_index] + result_index += 1 + if isinstance(imdb_info, dict): + meta['imdb_info'] = imdb_info + meta['tv_year'] = imdb_info.get('tv_year', None) + + elif isinstance(imdb_info, Exception): + console.print(f"[red]IMDb API call failed: {imdb_info}[/red]") + meta['imdb_info'] = meta.get('imdb_info', {}) + else: + console.print("[red]Unexpected IMDb response, setting imdb_info to empty.[/red]") + meta['imdb_info'] = {} + + if meta.get('category') == 'TV' and len(results) > result_index: + tvmaze_id = results[result_index] + result_index += 1 + + if isinstance(tvmaze_id, int): + meta['tvmaze_id'] = tvmaze_id + elif isinstance(tvmaze_id, Exception): + console.print(f"[yellow]TVMaze ID retrieval failed: {tvmaze_id}[/yellow]") + meta['tvmaze_id'] = 0 + + if meta.get('category') == 'TV' and not meta.get('tv_pack', False) and meta.get('episode_int') != 0: + if tvdb_api and tvdb_token and len(results) > result_index: + tvdb_episode_data = results[result_index] + result_index += 1 + + if tvdb_episode_data and not isinstance(tvdb_episode_data, Exception): + meta['tvdb_episode_data'] = tvdb_episode_data + meta['we_checked_tvdb'] = True + + if meta['tvdb_episode_data'].get('episode_name'): + episode_name = meta['tvdb_episode_data'].get('episode_name') + if episode_name and isinstance(episode_name, str) and episode_name.strip(): + if 'episode' in episode_name.lower(): + meta['auto_episode_title'] = None + meta['tvdb_episode_title'] = None + else: + meta['tvdb_episode_title'] = episode_name.strip() + meta['auto_episode_title'] = episode_name.strip() + else: + meta['auto_episode_title'] = None + + if meta['tvdb_episode_data'].get('overview'): + overview = meta['tvdb_episode_data'].get('overview') + if overview and isinstance(overview, str) and overview.strip(): + meta['overview_meta'] = overview.strip() + else: + meta['overview_meta'] = None + else: + meta['overview_meta'] = None + + if meta['tvdb_episode_data'].get('season_name'): + meta['tvdb_season_name'] = meta['tvdb_episode_data'].get('season_name') + + if meta['tvdb_episode_data'].get('season_number'): + meta['tvdb_season_number'] = meta['tvdb_episode_data'].get('season_number') + + if meta['tvdb_episode_data'].get('episode_number'): + meta['tvdb_episode_number'] = meta['tvdb_episode_data'].get('episode_number') + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('series_name'): + year = meta['tvdb_episode_data'].get('series_name') + year_match = re.search(r'\b(19\d\d|20[0-3]\d)\b', year) + if year_match: + meta['search_year'] = year_match.group(0) + else: + meta['search_year'] = "" + elif isinstance(tvdb_episode_data, Exception): + console.print(f"[yellow]TVDb episode data retrieval failed: {tvdb_episode_data}[/yellow]") + + if len(results) > result_index: + tmdb_episode_data = results[result_index] + result_index += 1 + + if not isinstance(tmdb_episode_data, Exception) and tmdb_episode_data: + meta['tmdb_episode_data'] = tmdb_episode_data + meta['we_checked_tmdb'] = True + + if meta.get('auto_episode_title') is None and tmdb_episode_data.get('name') is not None: + if 'episode' in tmdb_episode_data.get('name', '').lower(): + meta['auto_episode_title'] = None + else: + meta['auto_episode_title'] = tmdb_episode_data['name'] + + if meta.get('overview_meta') is None and tmdb_episode_data.get('overview') is not None: + meta['overview_meta'] = tmdb_episode_data.get('overview', None) + elif isinstance(tmdb_episode_data, Exception): + console.print(f"[yellow]TMDb episode data retrieval failed: {tmdb_episode_data}[/yellow]") + + elif meta.get('category') == 'TV' and meta.get('tv_pack', False) and tvdb_api and tvdb_token: + tvdb_series_data = results[result_index] + result_index += 1 + + if tvdb_series_data and not isinstance(tvdb_series_data, Exception): + meta['tvdb_series_name'] = tvdb_series_data + meta['we_checked_tvdb'] = True + elif isinstance(tvdb_series_data, Exception): + console.print(f"[yellow]TVDb series data retrieval failed: {tvdb_series_data}[/yellow]") + + return meta + + +async def imdb_tvdb(meta, filename, tvdb_api=None, tvdb_token=None): + if meta['debug']: + console.print("[yellow]Both IMDb and TVDB IDs are present[/yellow]") + tasks = [ + get_tmdb_from_imdb( + meta['imdb_id'], + meta.get('tvdb_id'), + meta.get('search_year'), + filename, + debug=meta.get('debug', False), + mode=meta.get('mode', 'discord'), + category_preference=meta.get('category') + ), + search_tvmaze( + filename, meta['search_year'], meta.get('imdb_id', 0), meta.get('tvdb_id', 0), + manual_date=meta.get('manual_date'), + tvmaze_manual=meta.get('tvmaze_manual'), + debug=meta.get('debug', False), + return_full_tuple=False + ), + get_imdb_info_api( + meta['imdb_id'], + manual_language=meta.get('manual_language'), + debug=meta.get('debug', False) + ) + ] + + # Add TVDb tasks if we have credentials and it's a TV show with episodes + add_tvdb_tasks = ( + tvdb_api and tvdb_token and + 'season_int' in meta and 'episode_int' in meta and + meta.get('category') == 'TV' and + not meta.get('tv_pack', False) and + meta.get('episode_int') != 0 + ) + + if add_tvdb_tasks: + tvdb_episode_data = await get_tvdb_episode_data( + meta['base_dir'], + tvdb_token, + meta.get('tvdb_id'), + meta.get('season_int'), + meta.get('episode_int'), + api_key=tvdb_api, + debug=meta.get('debug', False) + ) + + if tvdb_episode_data: + console.print("[green]TVDB episode data retrieved successfully.[/green]") + meta['tvdb_episode_data'] = tvdb_episode_data + meta['we_checked_tvdb'] = True + + # Process episode name + if meta['tvdb_episode_data'].get('episode_name'): + episode_name = meta['tvdb_episode_data'].get('episode_name') + if episode_name and isinstance(episode_name, str) and episode_name.strip(): + if 'episode' in episode_name.lower(): + meta['auto_episode_title'] = None + meta['tvdb_episode_title'] = None + else: + meta['tvdb_episode_title'] = episode_name.strip() + meta['auto_episode_title'] = episode_name.strip() + else: + meta['auto_episode_title'] = None + + # Process overview + if meta['tvdb_episode_data'].get('overview'): + overview = meta['tvdb_episode_data'].get('overview') + if overview and isinstance(overview, str) and overview.strip(): + meta['overview_meta'] = overview.strip() + else: + meta['overview_meta'] = None + else: + meta['overview_meta'] = None + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('series_name'): + year = meta['tvdb_episode_data'].get('series_name') + year_match = re.search(r'\b(19\d\d|20[0-3]\d)\b', year) + if year_match: + meta['search_year'] = year_match.group(0) + else: + meta['search_year'] = "" + + add_name_tasks = ( + tvdb_api and tvdb_token and + meta.get('category') == 'TV' and + meta.get('tv_pack', False) + ) + + if add_name_tasks: + tvdb_series_data = await get_tvdb_series_data( + meta['base_dir'], + tvdb_token, + meta.get('tvdb_id'), + api_key=tvdb_api, + debug=meta.get('debug', False) + ) + + if tvdb_series_data: + console.print("[green]TVDB series data retrieved successfully.[/green]") + meta['tvdb_series_name'] = tvdb_series_data + meta['we_checked_tvdb'] = True + + results = await asyncio.gather(*tasks, return_exceptions=True) + tmdb_result, tvmaze_id, imdb_info_result = results[:3] + if isinstance(tmdb_result, tuple) and len(tmdb_result) == 3: + meta['category'], meta['tmdb_id'], meta['original_language'] = tmdb_result + + meta['tvmaze_id'] = tvmaze_id if isinstance(tvmaze_id, int) else 0 + + if isinstance(imdb_info_result, dict): + meta['imdb_info'] = imdb_info_result + meta['tv_year'] = imdb_info_result.get('tv_year', None) + + elif isinstance(imdb_info_result, Exception): + console.print(f"[red]IMDb API call failed: {imdb_info_result}[/red]") + meta['imdb_info'] = meta.get('imdb_info', {}) # Keep previous IMDb info if it exists + else: + console.print("[red]Unexpected IMDb response, setting imdb_info to empty.[/red]") + meta['imdb_info'] = {} + return meta + + +async def imdb_tmdb(meta, filename): + # Create a list of coroutines to run concurrently + coroutines = [ + tmdb_other_meta( + tmdb_id=meta['tmdb_id'], + path=meta.get('path'), + search_year=meta.get('search_year'), + category=meta.get('category'), + imdb_id=meta.get('imdb_id', 0), + manual_language=meta.get('manual_language'), + anime=meta.get('anime', False), + mal_manual=meta.get('mal_manual'), + aka=meta.get('aka', ''), + original_language=meta.get('original_language'), + poster=meta.get('poster'), + debug=meta.get('debug', False), + mode=meta.get('mode', 'cli'), + tvdb_id=meta.get('tvdb_id', 0), + quickie_search=meta.get('quickie_search', False) + ), + get_imdb_info_api( + meta['imdb_id'], + manual_language=meta.get('manual_language'), + debug=meta.get('debug', False) + ) + ] + + # Add TVMaze search if it's a TV category + if meta['category'] == 'TV': + coroutines.append( + search_tvmaze( + filename, meta['search_year'], meta.get('imdb_id', 0), meta.get('tvdb_id', 0), + manual_date=meta.get('manual_date'), + tvmaze_manual=meta.get('tvmaze_manual'), + debug=meta.get('debug', False), + return_full_tuple=False + ) + ) + + # Add TMDb episode details if it's a TV show with episodes + if ('season_int' in meta and 'episode_int' in meta and + not meta.get('tv_pack', False) and + meta.get('episode_int') != 0): + coroutines.append( + get_episode_details( + meta.get('tmdb_id'), + meta.get('season_int'), + meta.get('episode_int'), + debug=meta.get('debug', False) + ) + ) + + # Gather results + results = await asyncio.gather(*coroutines, return_exceptions=True) + + tmdb_metadata = None + # Process the results + if isinstance(results[0], Exception): + error_msg = f"TMDB metadata retrieval failed: {str(results[0])}" + console.print(f"[bold red]{error_msg}[/bold red]") + pass + elif not results[0]: # Check if the result is empty (empty dict) + error_msg = f"Failed to retrieve essential metadata from TMDB ID: {meta['tmdb_id']}" + console.print(f"[bold red]{error_msg}[/bold red]") + pass + else: + tmdb_metadata = results[0] + + # Update meta with TMDB metadata + if tmdb_metadata: + meta.update(tmdb_metadata) + + imdb_info_result = results[1] + + # Process IMDb info + if isinstance(imdb_info_result, dict): + meta['imdb_info'] = imdb_info_result + meta['tv_year'] = imdb_info_result.get('tv_year', None) + + elif isinstance(imdb_info_result, Exception): + console.print(f"[red]IMDb API call failed: {imdb_info_result}[/red]") + meta['imdb_info'] = meta.get('imdb_info', {}) # Keep previous IMDb info if it exists + else: + console.print("[red]Unexpected IMDb response, setting imdb_info to empty.[/red]") + meta['imdb_info'] = {} + + # Process TVMaze results if it was included + if meta['category'] == 'TV': + if len(results) > 2: + tvmaze_result = results[2] + if isinstance(tvmaze_result, tuple) and len(tvmaze_result) == 3: + # Handle tuple return: (tvmaze_id, imdbID, tvdbID) + tvmaze_id, imdb_id, tvdb_id = tvmaze_result + meta['tvmaze_id'] = tvmaze_id if isinstance(tvmaze_id, int) else 0 + + # Set tvdb_id if not already set and we got a valid one + if not meta.get('tvdb_id', 0) and isinstance(tvdb_id, int) and tvdb_id > 0: + meta['tvdb_id'] = tvdb_id + if meta.get('debug'): + console.print(f"[green]Set TVDb ID from TVMaze: {tvdb_id}[/green]") + + elif isinstance(tvmaze_result, int): + meta['tvmaze_id'] = tvmaze_result + elif isinstance(tvmaze_result, Exception): + console.print(f"[red]TVMaze API call failed: {tvmaze_result}[/red]") + meta['tvmaze_id'] = 0 # Set default value if an exception occurred + else: + console.print(f"[yellow]Unexpected TVMaze result type: {type(tvmaze_result)}[/yellow]") + meta['tvmaze_id'] = 0 + + # Process TMDb episode details if they were included + if len(results) > 3: + episode_details_result = results[3] + if isinstance(episode_details_result, dict): + meta['tmdb_episode_data'] = episode_details_result + meta['we_checked_tmdb'] = True + + elif isinstance(episode_details_result, Exception): + console.print(f"[red]TMDb episode details API call failed: {episode_details_result}[/red]") + return meta + + +async def get_tvmaze_tvdb(meta, filename, tvdb_api=None, tvdb_token=None): + if meta['debug']: + console.print("[yellow]Both TVMaze and TVDb IDs are present[/yellow]") + # Core metadata tasks that run in parallel + tasks = [ + search_tvmaze( + filename, meta['search_year'], meta.get('imdb_id', 0), meta.get('tvdb_id', 0), + manual_date=meta.get('manual_date'), + tvmaze_manual=meta.get('tvmaze_manual'), + debug=meta.get('debug', False), + return_full_tuple=False + ) + ] + if tvdb_api and tvdb_token: + tasks.append( + get_tvdb_series( + meta['base_dir'], filename, meta.get('year', ''), + apikey=tvdb_api, token=tvdb_token, debug=meta.get('debug', False) + ) + ) + + results = await asyncio.gather(*tasks, return_exceptions=True) + + # Process TVMaze results + tvmaze_result = results[0] + if isinstance(tvmaze_result, tuple) and len(tvmaze_result) == 3: + # Handle tuple return: (tvmaze_id, imdbID, tvdbID) + tvmaze_id, imdb_id, tvdb_id = tvmaze_result + meta['tvmaze_id'] = tvmaze_id if isinstance(tvmaze_id, int) else 0 + + # Set tvdb_id if not already set and we got a valid one + if not meta.get('tvdb_id', 0) and isinstance(tvdb_id, int) and tvdb_id > 0: + meta['tvdb_id'] = int(tvdb_id) + if meta.get('debug'): + console.print(f"[green]Set TVDb ID from TVMaze: {tvdb_id}[/green]") + if not meta.get('imdb_id', 0) and isinstance(imdb_id, str) and imdb_id.strip(): + meta['imdb_id'] = int(imdb_id) + if meta.get('debug'): + console.print(f"[green]Set IMDb ID from TVMaze: {imdb_id}[/green]") + + elif isinstance(tvmaze_result, int): + meta['tvmaze_id'] = tvmaze_result + elif isinstance(tvmaze_result, Exception): + console.print(f"[red]TVMaze API call failed: {tvmaze_result}[/red]") + meta['tvmaze_id'] = 0 # Set default value if an exception occurred + else: + console.print(f"[yellow]Unexpected TVMaze result type: {type(tvmaze_result)}[/yellow]") + meta['tvmaze_id'] = 0 + + # Process TVDb results if we added that task + if len(results) > 1 and tvdb_api and tvdb_token: + tvdb_result = results[1] + if tvdb_result and not isinstance(tvdb_result, Exception): + meta['tvdb_id'] = int(tvdb_result) + if meta.get('debug'): + console.print(f"[green]Got TVDb series data: {tvdb_result}[/green]") + elif isinstance(tvdb_result, Exception): + console.print(f"[yellow]TVDb series data retrieval failed: {tvdb_result}[/yellow]") + + return meta + + +async def get_tv_data(meta, base_dir, tvdb_api=None, tvdb_token=None): + if not meta.get('tv_pack', False) and meta.get('episode_int') != 0: + if not meta.get('auto_episode_title') or not meta.get('overview_meta'): + # prioritize tvdb metadata if available + if tvdb_api and tvdb_token and not meta.get('we_checked_tvdb', False): + if meta['debug']: + console.print("[yellow]Fetching TVDb metadata...") + if meta.get('tvdb_id') and meta['tvdb_id'] != 0: + en_name = False + en_overview = False + meta['tvdb_season_int'], meta['tvdb_episode_int'], meta['tvdb_episode_id'] = await get_tvdb_series_episodes(base_dir, tvdb_token, meta.get('tvdb_id'), meta.get('season_int'), meta.get('episode_int'), tvdb_api, debug=meta.get('debug', False)) + if meta.get('tvdb_episode_id') and meta.get('tvdb_episode_id') != 0 and not meta.get('tv_pack', False): + meta['tvdb_episode_data'] = await get_tvdb_specific_episode_data(base_dir, tvdb_token, meta.get('tvdb_id'), meta.get('tvdb_episode_id'), api_key=tvdb_api, debug=meta.get('debug', False)) + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('eng_name'): + meta['tvdb_episode_data'].get('episode_name') == meta['tvdb_episode_data'].get('eng_name') + en_name = True + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('eng_overview'): + meta['tvdb_episode_data'].get('overview') == meta['tvdb_episode_data'].get('eng_overview') + en_overview = True + + result = await get_tvdb_episode_data(base_dir, tvdb_token, meta['tvdb_id'], meta.get('tvdb_season_int'), meta.get('tvdb_episode_int'), api_key=tvdb_api, debug=meta.get('debug', False)) + if result and result.get('series_name', ""): + if not isinstance(meta.get('tvdb_episode_data'), dict): + meta['tvdb_episode_data'] = result + else: + meta['tvdb_episode_data']['series_name'] = result.get('series_name', "") + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('episode_name') and meta.get('auto_episode_title') is None and meta.get('original_language', "") == "en": + episode_name = meta['tvdb_episode_data'].get('episode_name') + if episode_name and isinstance(episode_name, str) and episode_name.strip(): + if 'episode' in episode_name.lower(): + meta['auto_episode_title'] = None + meta['tvdb_episode_title'] = None + else: + meta['tvdb_episode_title'] = episode_name.strip() + meta['auto_episode_title'] = episode_name.strip() + elif en_name: + episode_name = meta['tvdb_episode_data'].get('eng_name') + if episode_name and isinstance(episode_name, str) and episode_name.strip(): + if 'episode' in episode_name.lower(): + meta['auto_episode_title'] = None + meta['tvdb_episode_title'] = None + else: + meta['tvdb_episode_title'] = episode_name.strip() + meta['auto_episode_title'] = episode_name.strip() + else: + meta['auto_episode_title'] = None + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('overview') and meta.get('original_language', "") == "en": + overview = meta['tvdb_episode_data'].get('overview') + if overview and isinstance(overview, str) and overview.strip(): + meta['overview_meta'] = overview.strip() + elif en_overview: + overview = meta['tvdb_episode_data'].get('eng_overview') + if overview and isinstance(overview, str) and overview.strip(): + meta['overview_meta'] = overview.strip() + else: + meta['overview_meta'] = None + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('season_name') and meta.get('original_language', "") == "en": + meta['tvdb_season_name'] = meta['tvdb_episode_data'].get('season_name') + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('season_number'): + meta['tvdb_season_number'] = meta['tvdb_episode_data'].get('season_number') + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('episode_number'): + meta['tvdb_episode_number'] = meta['tvdb_episode_data'].get('episode_number') + + if meta.get('tvdb_episode_data') and meta['tvdb_episode_data'].get('series_name'): + year = meta['tvdb_episode_data'].get('series_name') + year_match = re.search(r'\b(19\d\d|20[0-3]\d)\b', year) + if year_match: + meta['search_year'] = year_match.group(0) + else: + meta['search_year'] = "" + + # fallback to tvmaze data if tvdb data is available + if meta.get('auto_episode_title') is None or meta.get('overview_meta') is None and (not meta.get('we_asked_tvmaze', False) and meta.get('episode_overview', None)): + tvmaze_episode_data = await get_tvmaze_episode_data(meta.get('tvmaze_id'), meta.get('season_int'), meta.get('episode_int')) + if tvmaze_episode_data: + meta['tvmaze_episode_data'] = tvmaze_episode_data + if meta.get('auto_episode_title') is None and tvmaze_episode_data.get('name') is not None: + if 'episode' in tvmaze_episode_data.get("name").lower(): + meta['auto_episode_title'] = None + else: + meta['auto_episode_title'] = tvmaze_episode_data['name'] + if meta.get('overview_meta') is None and tvmaze_episode_data.get('overview') is not None: + meta['overview_meta'] = tvmaze_episode_data.get('overview', None) + + # fallback to tmdb data if no other data is not available + if (meta.get('auto_episode_title') is None or meta.get('overview_meta') is None) and (not meta.get('we_checked_tmdb', False) and meta.get('episode_overview', None)): + if 'tvdb_episode_int' in meta and meta.get('tvdb_episode_int') != 0 and meta.get('tvdb_episode_int') != meta.get('episode_int'): + episode = meta.get('episode_int') + season = meta.get('tvdb_season_int') + if meta['debug']: + console.print(f"[yellow]Using absolute episode number from TVDb: {episode}[/yellow]") + console.print(f"[yellow]Using matching season number from TVDb: {season}[/yellow]") + else: + episode = meta.get('episode_int') + season = meta.get('season_int') + if not meta.get('we_checked_tmdb', False): + if meta['debug']: + console.print("[yellow]Fetching TMDb episode metadata...") + episode_details = await get_episode_details(meta.get('tmdb_id'), season, episode, debug=meta.get('debug', False)) + else: + episode_details = meta.get('tmdb_episode_data', None) + if meta.get('auto_episode_title') is None and episode_details.get('name') is not None: + if 'episode' in episode_details.get("name").lower(): + meta['auto_episode_title'] = None + else: + meta['auto_episode_title'] = episode_details['name'] + if meta.get('overview_meta') is None and episode_details.get('overview') is not None: + meta['overview_meta'] = episode_details.get('overview', None) + + if 'tvdb_season_int' in meta and meta['tvdb_season_int'] and meta['tvdb_episode_int'] != 0: + meta['episode_int'] = meta['tvdb_episode_int'] + meta['season_int'] = meta['tvdb_season_int'] + meta['season'] = "S" + str(meta['season_int']).zfill(2) + meta['episode'] = "E" + str(meta['episode_int']).zfill(2) + elif meta.get('tv_pack', False): + if tvdb_api and tvdb_token: + meta['tvdb_series_name'] = await get_tvdb_series_data(base_dir, tvdb_token, meta.get('tvdb_id'), tvdb_api, debug=meta.get('debug', False)) + return meta diff --git a/src/nfo_link.py b/src/nfo_link.py new file mode 100644 index 000000000..37974f40f --- /dev/null +++ b/src/nfo_link.py @@ -0,0 +1,313 @@ +import os +import re +import subprocess +import datetime +from src.console import console +from data.config import config + + +async def create_season_nfo(season_folder, season_number, season_year, tvdbid, tvmazeid, plot, outline): + """Create a season.nfo file in the given season folder.""" + now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + nfo_content = f''' + + + + false + {now} + Season {season_number} + {season_year} + Season {season_number} + {tvdbid} + {tvdbid} + {tvmazeid} + {tvmazeid} + {season_number} +''' + nfo_path = os.path.join(season_folder, "season.nfo") + with open(nfo_path, "w", encoding="utf-8") as f: + f.write(nfo_content) + return nfo_path + + +async def nfo_link(meta): + """Create an Emby-compliant NFO file from metadata""" + try: + # Get basic info + imdb_info = meta.get('imdb_info', {}) + title = imdb_info.get('title', meta.get('title', '')) + if meta['category'] == "MOVIE": + year = imdb_info.get('year', meta.get('year', '')) + else: + year = meta.get('search_year', '') + plot = meta.get('overview', '') + rating = imdb_info.get('rating', '') + runtime = imdb_info.get('runtime', meta.get('runtime', '')) + genres = imdb_info.get('genres', meta.get('genres', '')) + country = imdb_info.get('country', meta.get('country', '')) + aka = imdb_info.get('aka', title) # Fallback to title if no aka + tagline = imdb_info.get('plot', '') + premiered = meta.get('release_date', '') + + # IDs + imdb_id = imdb_info.get('imdbID', meta.get('imdb_id', '')).replace('tt', '') + tmdb_id = meta.get('tmdb_id', '') + tvdb_id = meta.get('tvdb_id', '') + + # Cast and crew + cast = meta.get('cast', []) + directors = meta.get('directors', []) + studios = meta.get('studios', []) + + # Build NFO XML content with proper structure + nfo_content = ''' +''' + + # Add plot with CDATA + if plot: + nfo_content += f'\n ' + + # Add tagline if available + if tagline: + nfo_content += f'\n ' + nfo_content += f'\n {tagline}' + + # Basic metadata + nfo_content += f'\n {title}' + nfo_content += f'\n {aka}' + + # Add cast/actors + for actor in cast: + name = actor.get('name', '') + role = actor.get('character', actor.get('role', '')) + tmdb_actor_id = actor.get('id', '') + if name: + nfo_content += '\n ' + nfo_content += f'\n {name}' + if role: + nfo_content += f'\n {role}' + nfo_content += '\n Actor' + if tmdb_actor_id: + nfo_content += f'\n {tmdb_actor_id}' + nfo_content += '\n ' + + # Add directors + for director in directors: + director_name = director.get('name', director) if isinstance(director, dict) else director + director_id = director.get('id', '') if isinstance(director, dict) else '' + if director_name: + nfo_content += '\n {director_name}' + + # Add rating and year + if rating: + nfo_content += f'\n {rating}' + if year: + nfo_content += f'\n {year}' + + nfo_content += f'\n {title}' + + # Add IDs + if imdb_id: + nfo_content += f'\n tt{imdb_id}' + if tvdb_id: + nfo_content += f'\n {tvdb_id}' + if tmdb_id: + nfo_content += f'\n {tmdb_id}' + + # Add dates + if premiered: + nfo_content += f'\n {premiered}' + nfo_content += f'\n {premiered}' + + # Add runtime (convert to minutes if needed) + if runtime: + # Handle runtime in different formats + runtime_minutes = runtime + if isinstance(runtime, str) and 'min' in runtime: + runtime_minutes = runtime.replace('min', '').strip() + nfo_content += f'\n {runtime_minutes}' + + # Add country + if country: + nfo_content += f'\n {country}' + + # Add genres + if genres: + if isinstance(genres, str): + genre_list = [g.strip() for g in genres.split(',')] + else: + genre_list = genres + for genre in genre_list: + if genre: + nfo_content += f'\n {genre}' + + # Add studios + for studio in studios: + studio_name = studio.get('name', studio) if isinstance(studio, dict) else studio + if studio_name: + nfo_content += f'\n {studio_name}' + + # Add unique IDs + if tmdb_id: + nfo_content += f'\n {tmdb_id}' + if imdb_id: + nfo_content += f'\n tt{imdb_id}' + if tvdb_id: + nfo_content += f'\n {tvdb_id}' + + # Add legacy ID + if imdb_id: + nfo_content += f'\n tt{imdb_id}' + + nfo_content += '\n' + + # Save NFO file + movie_name = meta.get('title', 'movie') + # Remove or replace invalid characters: < > : " | ? * \ / + movie_name = re.sub(r'[<>:"|?*\\/]', '', movie_name) + meta['linking_failed'] = False + link_dir = await linking(meta, movie_name, year) + + uuid = meta.get('uuid') + filelist = meta.get('filelist', []) + if len(filelist) == 1 and os.path.isfile(filelist[0]) and not meta.get('keep_folder'): + # Single file - create symlink in the target folder + src_file = filelist[0] + filename = os.path.splitext(os.path.basename(src_file))[0] + else: + filename = uuid + + if meta['category'] == "TV" and link_dir is not None and not meta.get('linking_failed', False): + season_number = meta.get('season_int') or meta.get('season') or "1" + season_year = meta.get('search_year') or meta.get('year') or "" + tvdbid = meta.get('tvdb_id', '') + tvmazeid = meta.get('tvmaze_id', '') + plot = meta.get('overview', '') + outline = imdb_info.get('plot', '') + + season_folder = link_dir + if not os.path.exists(f"{season_folder}/season.nfo"): + await create_season_nfo( + season_folder, season_number, season_year, tvdbid, tvmazeid, plot, outline + ) + nfo_file_path = os.path.join(season_folder, "season.nfo") + + elif link_dir is not None and not meta.get('linking_failed', False): + nfo_file_path = os.path.join(link_dir, f"{filename}.nfo") + else: + if meta.get('linking_failed', False): + console.print("[red]Linking failed, saving NFO in data/nfos[/red]") + nfo_dir = os.path.join(f"{meta['base_dir']}/data/nfos/{meta['uuid']}/") + os.makedirs(nfo_dir, exist_ok=True) + nfo_file_path = os.path.join(nfo_dir, f"{filename}.nfo") + with open(nfo_file_path, 'w', encoding='utf-8') as f: + f.write(nfo_content) + + if meta['debug']: + console.print(f"[green]Emby NFO created at {nfo_file_path}") + + return nfo_file_path + + except Exception as e: + console.print(f"[red]Failed to create Emby NFO: {e}") + return None + + +async def linking(meta, movie_name, year): + if meta['category'] == "MOVIE": + if not meta['is_disc']: + folder_name = f"{movie_name} ({year})" + elif meta['is_disc'] == "BDMV": + folder_name = f"{movie_name} ({year}) - Disc" + else: + folder_name = f"{movie_name} ({year}) - {meta['is_disc']}" + else: + if not meta.get('search_year'): + if not meta['is_disc']: + folder_name = f"{movie_name}" + elif meta['is_disc'] == "BDMV": + folder_name = f"{movie_name} - Disc" + else: + folder_name = f"{movie_name} - {meta['is_disc']}" + else: + if not meta['is_disc']: + folder_name = f"{movie_name} ({meta['search_year']})" + elif meta['is_disc'] == "BDMV": + folder_name = f"{movie_name} ({meta['search_year']}) - Disc" + else: + folder_name = f"{movie_name} ({meta['search_year']}) - {meta['is_disc']}" + + if meta['category'] == "TV": + target_base = config['DEFAULT'].get('emby_tv_dir', None) + else: + target_base = config['DEFAULT'].get('emby_dir', None) + if target_base is not None: + if meta['category'] == "MOVIE": + target_dir = os.path.join(target_base, folder_name) + else: + if meta.get('season') == 'S00': + season = "Specials" + else: + season_int = str(meta.get('season_int')).zfill(2) + season = f"Season {season_int}" + target_dir = os.path.join(target_base, folder_name, season) + + os.makedirs(target_dir, exist_ok=True) + # Get source path and files + path = meta.get('path') + filelist = meta.get('filelist', []) + + if not path: + console.print("[red]No path found in meta.") + return None + + # Handle single file vs folder content + if len(filelist) == 1 and os.path.isfile(filelist[0]) and not meta.get('keep_folder'): + # Single file - create symlink in the target folder + src_file = filelist[0] + filename = os.path.basename(src_file) + target_file = os.path.join(target_dir, filename) + + try: + cmd = f'mklink "{target_file}" "{src_file}"' + subprocess.run(cmd, check=True, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + if meta.get('debug'): + console.print(f"[green]Created symlink: {target_file}") + + except subprocess.CalledProcessError: + meta['linking_failed'] = True + + else: + # Folder content - symlink all files from the source folder + src_dir = path if os.path.isdir(path) else os.path.dirname(path) + + # Get all files in the source directory + for root, dirs, files in os.walk(src_dir): + for file in files: + src_file = os.path.join(root, file) + # Create relative path structure in target + rel_path = os.path.relpath(src_file, src_dir) + target_file = os.path.join(target_dir, rel_path) + + # Create subdirectories if needed + target_file_dir = os.path.dirname(target_file) + os.makedirs(target_file_dir, exist_ok=True) + + try: + cmd = f'mklink "{target_file}" "{src_file}"' + subprocess.run(cmd, check=True, shell=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + if meta.get('debug'): + console.print(f"[green]Created symlink: {file}") + + except subprocess.CalledProcessError: + meta['linking_failed'] = True + + console.print(f"[green]Movie folder created: {target_dir}") + return target_dir + else: + return None diff --git a/src/prep.py b/src/prep.py index 7bbbd970a..321eff8c1 100644 --- a/src/prep.py +++ b/src/prep.py @@ -1,63 +1,53 @@ # -*- coding: utf-8 -*- -from src.args import Args -from src.console import console -from src.exceptions import * -from src.trackers.PTP import PTP -from src.trackers.BLU import BLU -from src.trackers.HDB import HDB -from src.trackers.COMMON import COMMON - try: - import traceback - import nest_asyncio - from src.discparse import DiscParse - import multiprocessing + import asyncio + import cli_ui + import ntpath import os - from os.path import basename import re - import math import sys - import distutils.util - import asyncio + import traceback + import time + + from difflib import SequenceMatcher from guessit import guessit - import ntpath from pathlib import Path - import urllib - import urllib.parse - import ffmpeg - import random - import json - import glob - import requests - import pyimgbox - from pymediainfo import MediaInfo - import tmdbsimple as tmdb - from datetime import datetime, date - from difflib import SequenceMatcher - from torf import Torrent - import base64 - import time - import anitopy - import shutil - from imdb import Cinemagoer - from subprocess import Popen - import subprocess - import itertools - import cli_ui - from rich.progress import Progress, TextColumn, BarColumn, TimeRemainingColumn - import platform + + from data.config import config + from src.apply_overrides import get_source_override + from src.audio import get_audio_v2 + from src.bluray_com import get_bluray_releases + from src.cleanup import cleanup, reset_terminal + from src.clients import Clients + from src.console import console + from src.edition import get_edition + from src.exportmi import exportInfo, mi_resolution, validate_mediainfo, get_conformance_error + from src.get_disc import get_disc, get_dvd_size + from src.get_name import extract_title_and_year + from src.getseasonep import get_season_episode + from src.get_source import get_source + from src.get_tracker_data import get_tracker_data, ping_unit3d + from src.imdb import get_imdb_info_api, search_imdb, get_imdb_from_episode + from src.is_scene import is_scene + from src.languages import parsed_mediainfo + from src.metadata_searching import all_ids, imdb_tvdb, imdb_tmdb, get_tv_data, imdb_tmdb_tvdb, get_tvdb_series, get_tvmaze_tvdb + from src.radarr import get_radarr_data + from src.region import get_region, get_distributor, get_service + from src.sonarr import get_sonarr_data + from src.tags import get_tag, tag_override + from src.tmdb import get_tmdb_imdb_from_mediainfo, get_tmdb_from_imdb, get_tmdb_id, set_tmdb_metadata + from src.tvmaze import search_tvmaze + from src.video import get_video_codec, get_video_encode, get_uhd, get_hdr, get_video, get_resolution, get_type, is_3d, is_sd, get_video_duration, get_container + except ModuleNotFoundError: console.print(traceback.print_exc()) - console.print('[bold red]Missing Module Found. Please reinstall required dependancies.') + console.print('[bold red]Missing Module Found. Please reinstall required dependencies.') console.print('[yellow]pip3 install --user -U -r requirements.txt') exit() except KeyboardInterrupt: exit() - - - class Prep(): """ Prepare for upload: @@ -66,2985 +56,969 @@ class Prep(): Database Identifiers (TMDB/IMDB/MAL/etc) Create Name """ + def __init__(self, screens, img_host, config): self.screens = screens self.config = config self.img_host = img_host.lower() - tmdb.API_KEY = config['DEFAULT']['tmdb_api'] - async def gather_prep(self, meta, mode): + # set a timer to check speed + if meta['debug']: + meta_start_time = time.time() + # set some details we'll need + meta['cutoff'] = int(self.config['DEFAULT'].get('cutoff_screens', 1)) + tvdb_api_get = str(self.config['DEFAULT'].get('tvdb_api', None)) + if tvdb_api_get is None or len(tvdb_api_get) < 20: + tvdb_api = None + else: + tvdb_api = tvdb_api_get + tvdb_token_get = str(self.config['DEFAULT'].get('tvdb_token', None)) + if tvdb_token_get is None or len(tvdb_token_get) < 20: + tvdb_token = None + else: + tvdb_token = tvdb_token_get meta['mode'] = mode - base_dir = os.path.abspath(os.path.dirname(os.path.dirname(__file__))) meta['isdir'] = os.path.isdir(meta['path']) base_dir = meta['base_dir'] - - if meta.get('uuid', None) == None: - folder_id = os.path.basename(meta['path']) - meta['uuid'] = folder_id + meta['saved_description'] = False + client = Clients(config=config) + meta['skip_auto_torrent'] = meta.get('skip_auto_torrent', False) or config['DEFAULT'].get('skip_auto_torrent', False) + hash_ids = ['infohash', 'torrent_hash', 'skip_auto_torrent'] + tracker_ids = ['aither', 'ulcx', 'lst', 'blu', 'oe', 'btn', 'bhd', 'huno', 'hdb', 'rf', 'otw', 'yus', 'dp', 'sp', 'ptp'] + use_sonarr = config['DEFAULT'].get('use_sonarr', False) + use_radarr = config['DEFAULT'].get('use_radarr', False) + meta['print_tracker_messages'] = config['DEFAULT'].get('print_tracker_messages', False) + meta['print_tracker_links'] = config['DEFAULT'].get('print_tracker_links', True) + only_id = config['DEFAULT'].get('only_id', False) if meta.get('onlyID') is None else meta.get('onlyID') + meta['only_id'] = only_id + meta['keep_images'] = config['DEFAULT'].get('keep_images', True) if not meta.get('keep_images') else True + mkbrr_threads = config['DEFAULT'].get('mkbrr_threads', "0") + meta['mkbrr_threads'] = mkbrr_threads + + # make sure these are set in meta + meta['we_checked_tvdb'] = False + meta['we_checked_tmdb'] = False + meta['we_asked_tvmaze'] = False + meta['audio_languages'] = None + meta['subtitle_languages'] = None + + folder_id = os.path.basename(meta['path']) + if meta.get('uuid', None) is None: + meta['uuid'] = folder_id if not os.path.exists(f"{base_dir}/tmp/{meta['uuid']}"): Path(f"{base_dir}/tmp/{meta['uuid']}").mkdir(parents=True, exist_ok=True) - + if meta['debug']: console.print(f"[cyan]ID: {meta['uuid']}") - - meta['is_disc'], videoloc, bdinfo, meta['discs'] = await self.get_disc(meta) - - # If BD: + meta['is_disc'], videoloc, bdinfo, meta['discs'] = await get_disc(meta) + + # Debugging information + # console.print(f"Debug: meta['filelist'] before population: {meta.get('filelist', 'Not Set')}") + if meta['is_disc'] == "BDMV": - video, meta['scene'], meta['imdb'] = self.is_scene(meta['path'], meta.get('imdb', None)) - meta['filelist'] = [] + video, meta['scene'], meta['imdb_id'] = await is_scene(meta['path'], meta, meta.get('imdb_id', 0)) + meta['filelist'] = [] # No filelist for discs, use path + search_term = os.path.basename(meta['path']) + search_file_folder = 'folder' try: - guess_name = bdinfo['title'].replace('-',' ') - filename = guessit(re.sub("[^0-9a-zA-Z\[\]]+", " ", guess_name), {"excludes" : ["country", "language"]})['title'] - untouched_filename = bdinfo['title'] + if meta.get('emby', False): + title, secondary_title, extracted_year = await extract_title_and_year(meta, video) + if meta['debug']: + console.print(f"Title: {title}, Secondary Title: {secondary_title}, Year: {extracted_year}") + if secondary_title: + meta['secondary_title'] = secondary_title + if extracted_year and not meta.get('year'): + meta['year'] = extracted_year + if title: + filename = title + untouched_filename = search_term + meta['regex_title'] = title + meta['regex_secondary_title'] = secondary_title + meta['regex_year'] = extracted_year + else: + guess_name = search_term.replace('-', ' ') + untouched_filename = search_term + filename = guessit(guess_name, {"excludes": ["country", "language"]})['title'] + else: + title, secondary_title, extracted_year = await extract_title_and_year(meta, video) + if meta['debug']: + console.print(f"Title: {title}, Secondary Title: {secondary_title}, Year: {extracted_year}") + if secondary_title: + meta['secondary_title'] = secondary_title + if extracted_year and not meta.get('year'): + meta['year'] = extracted_year + if title: + filename = title + untouched_filename = search_term + else: + guess_name = bdinfo['title'].replace('-', ' ') + untouched_filename = bdinfo['title'] + filename = guessit(re.sub(r"[^0-9a-zA-Z\[\\]]+", " ", guess_name), {"excludes": ["country", "language"]})['title'] try: meta['search_year'] = guessit(bdinfo['title'])['year'] except Exception: meta['search_year'] = "" except Exception: - guess_name = bdinfo['label'].replace('-',' ') - filename = guessit(re.sub("[^0-9a-zA-Z\[\]]+", " ", guess_name), {"excludes" : ["country", "language"]})['title'] + guess_name = bdinfo['label'].replace('-', ' ') + filename = guessit(re.sub(r"[^0-9a-zA-Z\[\\]]+", " ", guess_name), {"excludes": ["country", "language"]})['title'] untouched_filename = bdinfo['label'] try: meta['search_year'] = guessit(bdinfo['label'])['year'] except Exception: meta['search_year'] = "" - if meta.get('resolution', None) == None: - meta['resolution'] = self.mi_resolution(bdinfo['video'][0]['res'], guessit(video), width="OTHER", scan="p", height="OTHER", actual_height=0) - # if meta.get('sd', None) == None: - meta['sd'] = self.is_sd(meta['resolution']) + if meta.get('resolution', None) is None and not meta.get('emby', False): + meta['resolution'] = await mi_resolution(bdinfo['video'][0]['res'], guessit(video), width="OTHER", scan="p", height="OTHER", actual_height=0) + try: + is_hfr = bdinfo['video'][0]['fps'].split()[0] if bdinfo['video'] else "25" + if int(float(is_hfr)) > 30: + meta['hfr'] = True + else: + meta['hfr'] = False + except Exception: + meta['hfr'] = False + else: + meta['resolution'] = "1080p" + + meta['sd'] = await is_sd(meta['resolution']) mi = None - mi_dump = None - #IF DVD + elif meta['is_disc'] == "DVD": - video, meta['scene'], meta['imdb'] = self.is_scene(meta['path'], meta.get('imdb', None)) + video, meta['scene'], meta['imdb_id'] = await is_scene(meta['path'], meta, meta.get('imdb_id', 0)) meta['filelist'] = [] - guess_name = meta['discs'][0]['path'].replace('-',' ') - # filename = guessit(re.sub("[^0-9a-zA-Z]+", " ", guess_name))['title'] - filename = guessit(guess_name, {"excludes" : ["country", "language"]})['title'] - untouched_filename = os.path.basename(os.path.dirname(meta['discs'][0]['path'])) - try: - meta['search_year'] = guessit(meta['discs'][0]['path'])['year'] - except Exception: + search_term = os.path.basename(meta['path']) + search_file_folder = 'folder' + if meta.get('emby', False): + title, secondary_title, extracted_year = await extract_title_and_year(meta, video) + if meta['debug']: + console.print(f"Title: {title}, Secondary Title: {secondary_title}, Year: {extracted_year}") + if secondary_title: + meta['secondary_title'] = secondary_title + if extracted_year and not meta.get('year'): + meta['year'] = extracted_year + if title: + filename = title + untouched_filename = search_term + meta['regex_title'] = title + meta['regex_secondary_title'] = secondary_title + meta['regex_year'] = extracted_year + else: + guess_name = search_term.replace('-', ' ') + filename = guess_name + untouched_filename = search_term + meta['resolution'] = "480p" meta['search_year'] = "" - if meta.get('edit', False) == False: - mi = self.exportInfo(f"{meta['discs'][0]['path']}/VTS_{meta['discs'][0]['main_set'][0][:2]}_1.VOB", False, meta['uuid'], meta['base_dir'], export_text=False) - meta['mediainfo'] = mi else: - mi = meta['mediainfo'] - - #NTSC/PAL - meta['dvd_size'] = await self.get_dvd_size(meta['discs']) - meta['resolution'] = self.get_resolution(guessit(video), meta['uuid'], base_dir) - meta['sd'] = self.is_sd(meta['resolution']) + title, secondary_title, extracted_year = await extract_title_and_year(meta, video) + if meta['debug']: + console.print(f"Title: {title}, Secondary Title: {secondary_title}, Year: {extracted_year}") + if secondary_title: + meta['secondary_title'] = secondary_title + if extracted_year and not meta.get('year'): + meta['year'] = extracted_year + if title: + filename = title + untouched_filename = search_term + else: + guess_name = meta['discs'][0]['path'].replace('-', ' ') + filename = guessit(guess_name, {"excludes": ["country", "language"]})['title'] + untouched_filename = os.path.basename(os.path.dirname(meta['discs'][0]['path'])) + try: + meta['search_year'] = guessit(meta['discs'][0]['path'])['year'] + except Exception: + meta['search_year'] = "" + if not meta.get('edit', False): + mi = await exportInfo(f"{meta['discs'][0]['path']}/VTS_{meta['discs'][0]['main_set'][0][:2]}_1.VOB", False, meta['uuid'], meta['base_dir'], export_text=False, is_dvd=True, debug=meta.get('debug', False)) + meta['mediainfo'] = mi + else: + mi = meta['mediainfo'] + + meta['dvd_size'] = await get_dvd_size(meta['discs'], meta.get('manual_dvds')) + meta['resolution'], meta['hfr'] = await get_resolution(guessit(video), meta['uuid'], base_dir) + meta['sd'] = await is_sd(meta['resolution']) + elif meta['is_disc'] == "HDDVD": - video, meta['scene'], meta['imdb'] = self.is_scene(meta['path'], meta.get('imdb', None)) + video, meta['scene'], meta['imdb_id'] = await is_scene(meta['path'], meta, meta.get('imdb_id', 0)) meta['filelist'] = [] - guess_name = meta['discs'][0]['path'].replace('-','') - filename = guessit(guess_name, {"excludes" : ["country", "language"]})['title'] + search_term = os.path.basename(meta['path']) + search_file_folder = 'folder' + guess_name = meta['discs'][0]['path'].replace('-', '') + filename = guessit(guess_name, {"excludes": ["country", "language"]})['title'] untouched_filename = os.path.basename(meta['discs'][0]['path']) videopath = meta['discs'][0]['largest_evo'] try: meta['search_year'] = guessit(meta['discs'][0]['path'])['year'] except Exception: meta['search_year'] = "" - if meta.get('edit', False) == False: - mi = self.exportInfo(meta['discs'][0]['largest_evo'], False, meta['uuid'], meta['base_dir'], export_text=False) + if not meta.get('edit', False): + mi = await exportInfo(meta['discs'][0]['largest_evo'], False, meta['uuid'], meta['base_dir'], export_text=False, debug=meta['debug']) meta['mediainfo'] = mi else: mi = meta['mediainfo'] - meta['resolution'] = self.get_resolution(guessit(video), meta['uuid'], base_dir) - meta['sd'] = self.is_sd(meta['resolution']) - #If NOT BD/DVD/HDDVD + meta['resolution'], meta['hfr'] = await get_resolution(guessit(video), meta['uuid'], base_dir) + meta['sd'] = await is_sd(meta['resolution']) + else: - videopath, meta['filelist'] = self.get_video(videoloc, meta.get('mode', 'discord')) - video, meta['scene'], meta['imdb'] = self.is_scene(videopath, meta.get('imdb', None)) - guess_name = ntpath.basename(video).replace('-',' ') - filename = guessit(re.sub("[^0-9a-zA-Z\[\]]+", " ", guess_name), {"excludes" : ["country", "language"]}).get("title", guessit(re.sub("[^0-9a-zA-Z]+", " ", guess_name), {"excludes" : ["country", "language"]})["title"]) - untouched_filename = os.path.basename(video) - try: - meta['search_year'] = guessit(video)['year'] - except Exception: - meta['search_year'] = "" - - if meta.get('edit', False) == False: - mi = self.exportInfo(videopath, meta['isdir'], meta['uuid'], base_dir, export_text=True) - meta['mediainfo'] = mi + videopath, meta['filelist'] = await get_video(videoloc, meta.get('mode', 'discord'), meta.get('sorted_filelist', False)) + search_term = os.path.basename(meta['filelist'][0]) if meta['filelist'] else None + search_file_folder = 'file' + + video, meta['scene'], meta['imdb_id'] = await is_scene(videopath, meta, meta.get('imdb_id', 0)) + + title, secondary_title, extracted_year = await extract_title_and_year(meta, video) + if meta['debug']: + console.print(f"Title: {title}, Secondary Title: {secondary_title}, Year: {extracted_year}") + if secondary_title: + meta['secondary_title'] = secondary_title + if extracted_year and not meta.get('year'): + meta['year'] = extracted_year + + if meta.get('isdir', False): + guess_name = os.path.basename(meta['path']).replace("_", "").replace("-", "") if meta['path'] else "" else: - mi = meta['mediainfo'] + guess_name = ntpath.basename(video).replace('-', ' ') - if meta.get('resolution', None) == None: - meta['resolution'] = self.get_resolution(guessit(video), meta['uuid'], base_dir) - # if meta.get('sd', None) == None: - meta['sd'] = self.is_sd(meta['resolution']) - - - - if " AKA " in filename.replace('.',' '): - filename = filename.split('AKA')[0] - meta['filename'] = filename - - meta['bdinfo'] = bdinfo - - - - - - # Reuse information from other trackers - if str(self.config['TRACKERS'].get('PTP', {}).get('useAPI')).lower() == "true": - ptp = PTP(config=self.config) - if meta.get('ptp', None) != None: - meta['ptp_manual'] = meta['ptp'] - meta['imdb'], meta['ext_torrenthash'] = await ptp.get_imdb_from_torrent_id(meta['ptp']) - else: - if meta['is_disc'] in [None, ""]: - ptp_search_term = os.path.basename(meta['filelist'][0]) - search_file_folder = 'file' - else: - search_file_folder = 'folder' - ptp_search_term = os.path.basename(meta['path']) - ptp_imdb, ptp_id, meta['ext_torrenthash'] = await ptp.get_ptp_id_imdb(ptp_search_term, search_file_folder) - if ptp_imdb != None: - meta['imdb'] = ptp_imdb - if ptp_id != None: - meta['ptp'] = ptp_id - - if str(self.config['TRACKERS'].get('HDB', {}).get('useAPI')).lower() == "true": - hdb = HDB(config=self.config) - if meta.get('ptp', None) == None or meta.get('hdb', None) != None: - hdb_imdb = hdb_tvdb = hdb_id = None - hdb_id = meta.get('hdb') - if hdb_id != None: - meta['hdb_manual'] = hdb_id - hdb_imdb, hdb_tvdb, meta['hdb_name'], meta['ext_torrenthash'] = await hdb.get_info_from_torrent_id(hdb_id) - else: - if meta['is_disc'] in [None, ""]: - hdb_imdb, hdb_tvdb, meta['hdb_name'], meta['ext_torrenthash'], hdb_id = await hdb.search_filename(meta['filelist']) - else: - # Somehow search for disc - pass - if hdb_imdb != None: - meta['imdb'] = str(hdb_imdb) - if hdb_tvdb != None: - meta['tvdb_id'] = str(hdb_tvdb) - if hdb_id != None: - meta['hdb'] = hdb_id - - if str(self.config['TRACKERS'].get('BLU', {}).get('useAPI')).lower() == "true": - blu = BLU(config=self.config) - if meta.get('blu', None) != None: - meta['blu_manual'] = meta['blu'] - blu_tmdb, blu_imdb, blu_tvdb, blu_mal, blu_desc, blu_category, meta['ext_torrenthash'], blu_imagelist = await COMMON(self.config).unit3d_torrent_info("BLU", blu.torrent_url, meta['blu']) - if blu_tmdb not in [None, '0']: - meta['tmdb_manual'] = blu_tmdb - if blu_imdb not in [None, '0']: - meta['imdb'] = str(blu_imdb) - if blu_tvdb not in [None, '0']: - meta['tvdb_id'] = blu_tvdb - if blu_mal not in [None, '0']: - meta['mal'] = blu_mal - if blu_desc not in [None, '0', '']: - meta['blu_desc'] = blu_desc - if blu_category.upper() in ['MOVIE', 'TV SHOW', 'FANRES']: - if blu_category.upper() == 'TV SHOW': - meta['category'] = 'TV' - else: - meta['category'] = blu_category.upper() - if meta.get('image_list', []) == []: - meta['image_list'] = blu_imagelist + if title: + filename = title + meta['regex_title'] = title + meta['regex_secondary_title'] = secondary_title + meta['regex_year'] = extracted_year else: - # Seach automatically - pass - - - - - - # Take Screenshots - if meta['is_disc'] == "BDMV": - if meta.get('edit', False) == False: - if meta.get('vapoursynth', False) == True: - use_vs = True - else: - use_vs = False - try: - ds = multiprocessing.Process(target=self.disc_screenshots, args=(filename, bdinfo, meta['uuid'], base_dir, use_vs, meta.get('image_list', []), meta.get('ffdebug', False), None)) - ds.start() - while ds.is_alive() == True: - await asyncio.sleep(1) - except KeyboardInterrupt: - ds.terminate() - elif meta['is_disc'] == "DVD": - if meta.get('edit', False) == False: - try: - ds = multiprocessing.Process(target=self.dvd_screenshots, args=(meta, 0, None)) - ds.start() - while ds.is_alive() == True: - await asyncio.sleep(1) - except KeyboardInterrupt: - ds.terminate() - else: - if meta.get('edit', False) == False: try: - s = multiprocessing.Process(target=self.screenshots, args=(videopath, filename, meta['uuid'], base_dir, meta)) - s.start() - while s.is_alive() == True: - await asyncio.sleep(3) - except KeyboardInterrupt: - s.terminate() - - - - - meta['tmdb'] = meta.get('tmdb_manual', None) - if meta.get('type', None) == None: - meta['type'] = self.get_type(video, meta['scene'], meta['is_disc']) - if meta.get('category', None) == None: - meta['category'] = self.get_cat(video) - else: - meta['category'] = meta['category'].upper() - if meta.get('tmdb', None) == None and meta.get('imdb', None) == None: - meta['category'], meta['tmdb'], meta['imdb'] = self.get_tmdb_imdb_from_mediainfo(mi, meta['category'], meta['is_disc'], meta['tmdb'], meta['imdb']) - if meta.get('tmdb', None) == None and meta.get('imdb', None) == None: - meta = await self.get_tmdb_id(filename, meta['search_year'], meta, meta['category'], untouched_filename) - elif meta.get('imdb', None) != None and meta.get('tmdb_manual', None) == None: - meta['imdb_id'] = str(meta['imdb']).replace('tt', '') - meta = await self.get_tmdb_from_imdb(meta, filename) - else: - meta['tmdb_manual'] = meta.get('tmdb', None) - - - # If no tmdb, use imdb for meta - if int(meta['tmdb']) == 0: - meta = await self.imdb_other_meta(meta) - else: - meta = await self.tmdb_other_meta(meta) - # Search tvmaze - meta['tvmaze_id'], meta['imdb_id'], meta['tvdb_id'] = await self.search_tvmaze(filename, meta['search_year'], meta.get('imdb_id','0'), meta.get('tvdb_id', 0)) - # If no imdb, search for it - if meta.get('imdb_id', None) == None: - meta['imdb_id'] = await self.search_imdb(filename, meta['search_year']) - if meta.get('imdb_info', None) == None and int(meta['imdb_id']) != 0: - meta['imdb_info'] = await self.get_imdb_info(meta['imdb_id'], meta) - if meta.get('tag', None) == None: - meta['tag'] = self.get_tag(video, meta) - else: - if not meta['tag'].startswith('-') and meta['tag'] != "": - meta['tag'] = f"-{meta['tag']}" - meta = await self.get_season_episode(video, meta) - meta = await self.tag_override(meta) - - meta['video'] = video - meta['audio'], meta['channels'], meta['has_commentary'] = self.get_audio_v2(mi, meta, bdinfo) - if meta['tag'][1:].startswith(meta['channels']): - meta['tag'] = meta['tag'].replace(f"-{meta['channels']}", '') - if meta.get('no_tag', False): - meta['tag'] = "" - meta['3D'] = self.is_3d(mi, bdinfo) - meta['source'], meta['type'] = self.get_source(meta['type'], video, meta['path'], meta['is_disc'], meta) - if meta.get('service', None) in (None, ''): - meta['service'], meta['service_longname'] = self.get_service(video, meta.get('tag', ''), meta['audio'], meta['filename']) - meta['uhd'] = self.get_uhd(meta['type'], guessit(meta['path']), meta['resolution'], meta['path']) - meta['hdr'] = self.get_hdr(mi, bdinfo) - meta['distributor'] = self.get_distributor(meta['distributor']) - if meta.get('is_disc', None) == "BDMV": #Blu-ray Specific - meta['region'] = self.get_region(bdinfo, meta.get('region', None)) - meta['video_codec'] = self.get_video_codec(bdinfo) - else: - meta['video_encode'], meta['video_codec'], meta['has_encode_settings'], meta['bit_depth'] = self.get_video_encode(mi, meta['type'], bdinfo) - - meta['edition'], meta['repack'] = self.get_edition(meta['path'], bdinfo, meta['filelist'], meta.get('manual_edition')) - if "REPACK" in meta.get('edition', ""): - meta['repack'] = re.search("REPACK[\d]?", meta['edition'])[0] - meta['edition'] = re.sub("REPACK[\d]?", "", meta['edition']).strip().replace(' ', ' ') - - - - #WORK ON THIS - meta.get('stream', False) - meta['stream'] = self.stream_optimized(meta['stream']) - meta.get('anon', False) - meta['anon'] = self.is_anon(meta['anon']) - - - - meta = await self.gen_desc(meta) - return meta + filename = guessit(re.sub(r"[^0-9a-zA-Z\[\\]]+", " ", guess_name), {"excludes": ["country", "language"]}).get("title", guessit(re.sub("[^0-9a-zA-Z]+", " ", guess_name), {"excludes": ["country", "language"]})["title"]) + except Exception: + try: + guess_name = ntpath.basename(video).replace('-', ' ') + filename = guessit(re.sub(r"[^0-9a-zA-Z\[\\]]+", " ", guess_name), {"excludes": ["country", "language"]}).get("title", guessit(re.sub("[^0-9a-zA-Z]+", " ", guess_name), {"excludes": ["country", "language"]})["title"]) + except Exception: + console.print("[red]Error extracting title from video name.") + sys.exit(0) + untouched_filename = os.path.basename(video) + if not meta.get('emby', False): + # rely only on guessit for search_year for tv matching + try: + meta['search_year'] = guessit(video)['year'] + except Exception: + meta['search_year'] = "" + if not meta.get('edit', False): + mi = await exportInfo(videopath, meta['isdir'], meta['uuid'], base_dir, export_text=True) + meta['mediainfo'] = mi + else: + mi = meta['mediainfo'] + if meta.get('resolution', None) is None: + meta['resolution'], meta['hfr'] = await get_resolution(guessit(video), meta['uuid'], base_dir) - """ - Determine if disc and if so, get bdinfo - """ - async def get_disc(self, meta): - is_disc = None - videoloc = meta['path'] - bdinfo = None - bd_summary = None - discs = [] - parse = DiscParse() - for path, directories, files in os. walk(meta['path']): - for each in directories: - if each.upper() == "BDMV": #BDMVs - is_disc = "BDMV" - disc = { - 'path' : f"{path}/{each}", - 'name' : os.path.basename(path), - 'type' : 'BDMV', - 'summary' : "", - 'bdinfo' : "" - } - discs.append(disc) - elif each == "VIDEO_TS": #DVDs - is_disc = "DVD" - disc = { - 'path' : f"{path}/{each}", - 'name' : os.path.basename(path), - 'type' : 'DVD', - 'vob_mi' : '', - 'ifo_mi' : '', - 'main_set' : [], - 'size' : "" - } - discs.append(disc) - elif each == "HVDVD_TS": - is_disc = "HDDVD" - disc = { - 'path' : f"{path}/{each}", - 'name' : os.path.basename(path), - 'type' : 'HDDVD', - 'evo_mi' : '', - 'largest_evo' : "" - } - discs.append(disc) - if is_disc == "BDMV": - if meta.get('edit', False) == False: - discs, bdinfo = await parse.get_bdinfo(discs, meta['uuid'], meta['base_dir'], meta.get('discs', [])) + meta['sd'] = await is_sd(meta['resolution']) else: - discs, bdinfo = await parse.get_bdinfo(meta['discs'], meta['uuid'], meta['base_dir'], meta['discs']) - elif is_disc == "DVD": - discs = await parse.get_dvdinfo(discs) - export = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'w', newline="", encoding='utf-8') - export.write(discs[0]['ifo_mi']) - export.close() - elif is_disc == "HDDVD": - discs = await parse.get_hddvd_info(discs) - export = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'w', newline="", encoding='utf-8') - export.write(discs[0]['evo_mi']) - export.close() - discs = sorted(discs, key=lambda d: d['name']) - return is_disc, videoloc, bdinfo, discs - - - + meta['resolution'] = "1080p" + meta['search_year'] = "" - """ - Get video files + if " AKA " in filename.replace('.', ' '): + filename = filename.split('AKA')[0] + meta['filename'] = filename + meta['bdinfo'] = bdinfo - """ - def get_video(self, videoloc, mode): - filelist = [] - videoloc = os.path.abspath(videoloc) - if os.path.isdir(videoloc): - globlist = glob.glob1(videoloc, "*.mkv") + glob.glob1(videoloc, "*.mp4") + glob.glob1(videoloc, "*.ts") - for file in globlist: - if not file.lower().endswith('sample.mkv') or "!sample" in file.lower(): - filelist.append(os.path.abspath(f"{videoloc}{os.sep}{file}")) + conform_issues = await get_conformance_error(meta) + if conform_issues: + upload = False + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + try: + upload = cli_ui.ask_yes_no("Found Conformance errors in mediainfo (possible cause: corrupted file, incomplete download, new codec, etc...), proceed to upload anyway?", default=False) + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + if upload is False: + console.print("[red]Not uploading. Check if the file has finished downloading and can be played back properly (uncorrupted).") + tmp_dir = f"{meta['base_dir']}/tmp/{meta['uuid']}" + # Cleanup meta so we don't reuse it later + if os.path.exists(tmp_dir): + try: + for file in os.listdir(tmp_dir): + file_path = os.path.join(tmp_dir, file) + if os.path.isfile(file_path) and file.endswith((".txt", ".json")): + os.remove(file_path) + if meta['debug']: + console.print(f"[yellow]Removed temporary metadata file: {file_path}[/yellow]") + except Exception as e: + console.print(f"[red]Error cleaning up temporary metadata files: {e}[/red]", highlight=False) + # Exit with error code for automation + sys.exit(1) + + meta['valid_mi'] = True + if not meta['is_disc'] and not meta.get('emby', False): + valid_mi = validate_mediainfo(base_dir, folder_id, path=meta['path'], filelist=meta['filelist'], debug=meta['debug']) + if not valid_mi: + console.print("[red]MediaInfo validation failed. This file does not contain (Unique ID).") + meta['valid_mi'] = False + await asyncio.sleep(2) + + # Check if there's a language restriction + if meta['has_languages'] is not None and not meta.get('emby', False): try: - video = sorted(filelist)[0] - except IndexError: - console.print("[bold red]No Video files found") - if mode == 'cli': - exit() - else: - video = videoloc - filelist.append(videoloc) - filelist = sorted(filelist) - return video, filelist - - - - - - - """ - Get and parse mediainfo - """ - def exportInfo(self, video, isdir, folder_id, base_dir, export_text): - if os.path.exists(f"{base_dir}/tmp/{folder_id}/MEDIAINFO.txt") == False and export_text != False: - console.print("[bold yellow]Exporting MediaInfo...") - #MediaInfo to text - if isdir == False: - os.chdir(os.path.dirname(video)) - media_info = MediaInfo.parse(video, output="STRING", full=False, mediainfo_options={'inform_version' : '1'}) - with open(f"{base_dir}/tmp/{folder_id}/MEDIAINFO.txt", 'w', newline="", encoding='utf-8') as export: - export.write(media_info) - export.close() - with open(f"{base_dir}/tmp/{folder_id}/MEDIAINFO_CLEANPATH.txt", 'w', newline="", encoding='utf-8') as export_cleanpath: - export_cleanpath.write(media_info.replace(video, os.path.basename(video))) - export_cleanpath.close() - console.print("[bold green]MediaInfo Exported.") - - if os.path.exists(f"{base_dir}/tmp/{folder_id}/MediaInfo.json.txt") == False: - #MediaInfo to JSON - media_info = MediaInfo.parse(video, output="JSON", mediainfo_options={'inform_version' : '1'}) - export = open(f"{base_dir}/tmp/{folder_id}/MediaInfo.json", 'w', encoding='utf-8') - export.write(media_info) - export.close() - with open(f"{base_dir}/tmp/{folder_id}/MediaInfo.json", 'r', encoding='utf-8') as f: - mi = json.load(f) - - return mi + audio_languages = [] + parsed_info = await parsed_mediainfo(meta) + for audio_track in parsed_info.get('audio', []): + if 'language' in audio_track and audio_track['language']: + audio_languages.append(audio_track['language'].lower()) + any_of_languages = meta['has_languages'].lower().split(",") + if all(len(lang.strip()) == 2 for lang in any_of_languages): + raise Exception(f"Warning: Languages should be full names, not ISO codes. Found: {any_of_languages}") + # We need to have user input languages and file must have audio tracks. + if len(any_of_languages) > 0 and len(audio_languages) > 0 and not set(any_of_languages).intersection(set(audio_languages)): + console.print(f"[red] None of the required languages ({meta['has_languages']}) is available on the file {audio_languages}") + raise Exception("No matching languages") + except Exception as e: + console.print(f"[red]Error checking languages: {e}") + + if not meta.get('emby', False): + if 'description' not in meta or meta.get('description') is None: + meta['description'] = "" + + description_text = meta.get('description', '') + if description_text is None: + description_text = "" + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'w', newline="", encoding='utf8') as description: + if len(description_text): + description.write(description_text) + + meta['skip_trackers'] = False + if meta.get('emby', False): + only_id = True + meta['only_id'] = True + meta['keep_images'] = False + if meta.get('imdb_id', 0) != 0: + meta['skip_trackers'] = True + if meta['debug']: + pathed_time_start = time.time() + # auto torrent searching with qbittorrent that grabs torrent ids for metadata searching + if not any(meta.get(id_type) for id_type in hash_ids + tracker_ids) and not meta.get('skip_trackers', False) and not meta.get('edit', False): + await client.get_pathed_torrents(meta['path'], meta) + if meta['debug']: + pathed_time_end = time.time() + console.print(f"Pathed torrent data processed in {pathed_time_end - pathed_time_start:.2f} seconds") - """ - Get Resolution - """ + # Ensure all manual IDs have proper default values + meta['tmdb_manual'] = meta.get('tmdb_manual') or 0 + meta['imdb_manual'] = meta.get('imdb_manual') or 0 + meta['mal_manual'] = meta.get('mal_manual') or 0 + meta['tvdb_manual'] = meta.get('tvdb_manual') or 0 + meta['tvmaze_manual'] = meta.get('tvmaze_manual') or 0 - def get_resolution(self, guess, folder_id, base_dir): - with open(f'{base_dir}/tmp/{folder_id}/MediaInfo.json', 'r', encoding='utf-8') as f: - mi = json.load(f) - try: - width = mi['media']['track'][1]['Width'] - height = mi['media']['track'][1]['Height'] - except: - width = 0 - height = 0 - framerate = mi['media']['track'][1].get('FrameRate', '') - try: - scan = mi['media']['track'][1]['ScanType'] - except: - scan = "Progressive" - if scan == "Progressive": - scan = "p" - elif framerate == "25.000": - scan = "p" - else: - scan = "i" - width_list = [3840, 2560, 1920, 1280, 1024, 854, 720, 15360, 7680, 0] - height_list = [2160, 1440, 1080, 720, 576, 540, 480, 8640, 4320, 0] - width = self.closest(width_list, int(width)) - actual_height = int(height) - height = self.closest(height_list, int(height)) - res = f"{width}x{height}{scan}" - resolution = self.mi_resolution(res, guess, width, scan, height, actual_height) - return resolution - - def closest(self, lst, K): - # Get closest, but not over - lst = sorted(lst) - mi_input = K - res = 0 - for each in lst: - if mi_input > each: - pass - else: - res = each - break - return res - - # return lst[min(range(len(lst)), key = lambda i: abs(lst[i]-K))] - - def mi_resolution(self, res, guess, width, scan, height, actual_height): - res_map = { - "3840x2160p" : "2160p", "2160p" : "2160p", - "2560x1440p" : "1440p", "1440p" : "1440p", - "1920x1080p" : "1080p", "1080p" : "1080p", - "1920x1080i" : "1080i", "1080i" : "1080i", - "1280x720p" : "720p", "720p" : "720p", - "1280x540p" : "720p", "1280x576p" : "720p", - "1024x576p" : "576p", "576p" : "576p", - "1024x576i" : "576i", "576i" : "576i", - "854x480p" : "480p", "480p" : "480p", - "854x480i" : "480i", "480i" : "480i", - "720x576p" : "576p", "576p" : "576p", - "720x576i" : "576i", "576i" : "576i", - "720x480p" : "480p", "480p" : "480p", - "720x480i" : "480i", "480i" : "480i", - "15360x8640p" : "8640p", "8640p" : "8640p", - "7680x4320p" : "4320p", "4320p" : "4320p", - "OTHER" : "OTHER"} - resolution = res_map.get(res, None) - if actual_height == 540: - resolution = "OTHER" - if resolution == None: - try: - resolution = guess['screen_size'] - except: - width_map = { - '3840p' : '2160p', - '2560p' : '1550p', - '1920p' : '1080p', - '1920i' : '1080i', - '1280p' : '720p', - '1024p' : '576p', - '1024i' : '576i', - '854p' : '480p', - '854i' : '480i', - '720p' : '576p', - '720i' : '576i', - '15360p' : '4320p', - 'OTHERp' : 'OTHER' - } - resolution = width_map.get(f"{width}{scan}", "OTHER") - resolution = self.mi_resolution(resolution, guess, width, scan, height, actual_height) - - return resolution - - - - def is_sd(self, resolution): - if resolution in ("480i", "480p", "576i", "576p", "540p"): - sd = 1 - else: - sd = 0 - return sd + # Set tmdb_id + try: + meta['tmdb_id'] = int(meta['tmdb_manual']) + except (ValueError, TypeError): + meta['tmdb_id'] = 0 - """ - Is a scene release? - """ - def is_scene(self, video, imdb=None): - scene = False - base = os.path.basename(video) - base = os.path.splitext(base)[0] - base = urllib.parse.quote(base) - url = f"https://api.srrdb.com/v1/search/r:{base}" + # Set imdb_id with proper handling for 'tt' prefix try: - response = requests.get(url, timeout=30) - response = response.json() - if int(response.get('resultsCount', 0)) != 0: - video = f"{response['results'][0]['release']}.mkv" - scene = True - r = requests.get(f"https://api.srrdb.com/v1/imdb/{base}") - r = r.json() - if r['releases'] != [] and imdb == None: - imdb = r['releases'][0].get('imdb', imdb) if r['releases'][0].get('imdb') is not None else imdb - console.print(f"[green]SRRDB: Matched to {response['results'][0]['release']}") - except Exception: - video = video - scene = False - console.print("[yellow]SRRDB: No match found, or request has timed out") - return video, scene, imdb + if not meta.get('imdb_id'): + imdb_value = meta['imdb_manual'] + if imdb_value: + if str(imdb_value).startswith('tt'): + meta['imdb_id'] = int(str(imdb_value)[2:]) + else: + meta['imdb_id'] = int(imdb_value) + else: + meta['imdb_id'] = 0 + except (ValueError, TypeError): + meta['imdb_id'] = 0 + # Set mal_id + try: + meta['mal_id'] = int(meta['mal_manual']) + except (ValueError, TypeError): + meta['mal_id'] = 0 + # Set tvdb_id + try: + meta['tvdb_id'] = int(meta['tvdb_manual']) + except (ValueError, TypeError): + meta['tvdb_id'] = 0 + try: + meta['tvmaze_id'] = int(meta['tvmaze_manual']) + except (ValueError, TypeError): + meta['tvmaze_id'] = 0 + if not meta.get('category', None): + meta['category'] = await self.get_cat(video, meta) + else: + meta['category'] = meta['category'].upper() + ids = None + if not meta.get('skip_trackers', False): + if meta.get('category', None) == "TV" and use_sonarr and meta.get('tvdb_id', 0) == 0: + ids = await get_sonarr_data(filename=meta.get('path', ''), title=meta.get('filename', None), debug=meta.get('debug', False)) + if ids: + if meta['debug']: + console.print(f"TVDB ID: {ids['tvdb_id']}") + console.print(f"IMDB ID: {ids['imdb_id']}") + console.print(f"TVMAZE ID: {ids['tvmaze_id']}") + console.print(f"TMDB ID: {ids['tmdb_id']}") + console.print(f"Genres: {ids['genres']}") + console.print(f"Release Group: {ids['release_group']}") + console.print(f"Year: {ids['year']}") + if 'anime' not in [genre.lower() for genre in ids['genres']]: + meta['not_anime'] = True + if meta.get('tvdb_id', 0) == 0 and ids['tvdb_id'] is not None: + meta['tvdb_id'] = ids['tvdb_id'] + if meta.get('imdb_id', 0) == 0 and ids['imdb_id'] is not None: + meta['imdb_id'] = ids['imdb_id'] + if meta.get('tvmaze_id', 0) == 0 and ids['tvmaze_id'] is not None: + meta['tvmaze_id'] = ids['tvmaze_id'] + if meta.get('tmdb_id', 0) == 0 and ids['tmdb_id'] is not None: + meta['tmdb_id'] = ids['tmdb_id'] + if meta.get('tag', None) is None: + meta['tag'] = ids['release_group'] + if meta.get('manual_year', 0) == 0 and ids['year'] is not None: + meta['manual_year'] = ids['year'] + else: + ids = None + + if meta.get('category', None) == "MOVIE" and use_radarr and meta.get('tmdb_id', 0) == 0: + ids = await get_radarr_data(filename=meta.get('uuid', ''), debug=meta.get('debug', False)) + if ids: + if meta['debug']: + console.print(f"IMDB ID: {ids['imdb_id']}") + console.print(f"TMDB ID: {ids['tmdb_id']}") + console.print(f"Genres: {ids['genres']}") + console.print(f"Year: {ids['year']}") + console.print(f"Release Group: {ids['release_group']}") + if meta.get('imdb_id', 0) == 0 and ids['imdb_id'] is not None: + meta['imdb_id'] = ids['imdb_id'] + if meta.get('tmdb_id', 0) == 0 and ids['tmdb_id'] is not None: + meta['tmdb_id'] = ids['tmdb_id'] + if meta.get('manual_year', 0) == 0 and ids['year'] is not None: + meta['manual_year'] = ids['year'] + if meta.get('tag', None) is None: + meta['tag'] = ids['release_group'] + else: + ids = None + + # check if we've already searched torrents + if 'base_torrent_created' not in meta: + meta['base_torrent_created'] = False + if 'we_checked_them_all' not in meta: + meta['we_checked_them_all'] = False + + # if not auto qbittorrent search, this also checks with the infohash if passed. + if meta.get('infohash') is not None and not meta['base_torrent_created'] and not meta['we_checked_them_all'] and not ids: + meta = await client.get_ptp_from_hash(meta) + + if not meta.get('image_list') and not meta.get('edit', False) and not ids: + # Reuse information from trackers with fallback + await get_tracker_data(video, meta, search_term, search_file_folder, meta['category'], only_id=only_id) + + if meta.get('category', None) == "TV" and use_sonarr and meta.get('tvdb_id', 0) != 0 and ids is None and not meta.get('matched_tracker', None): + ids = await get_sonarr_data(tvdb_id=meta.get('tvdb_id', 0), debug=meta.get('debug', False)) + if ids: + if meta['debug']: + console.print(f"TVDB ID: {ids['tvdb_id']}") + console.print(f"IMDB ID: {ids['imdb_id']}") + console.print(f"TVMAZE ID: {ids['tvmaze_id']}") + console.print(f"TMDB ID: {ids['tmdb_id']}") + console.print(f"Genres: {ids['genres']}") + if 'anime' not in [genre.lower() for genre in ids['genres']]: + meta['not_anime'] = True + if meta.get('tvdb_id', 0) == 0 and ids['tvdb_id'] is not None: + meta['tvdb_id'] = ids['tvdb_id'] + if meta.get('imdb_id', 0) == 0 and ids['imdb_id'] is not None: + meta['imdb_id'] = ids['imdb_id'] + if meta.get('tvmaze_id', 0) == 0 and ids['tvmaze_id'] is not None: + meta['tvmaze_id'] = ids['tvmaze_id'] + if meta.get('tmdb_id', 0) == 0 and ids['tmdb_id'] is not None: + meta['tmdb_id'] = ids['tmdb_id'] + if meta.get('tag', None) is None: + meta['tag'] = ids['release_group'] + if meta.get('manual_year', 0) == 0 and ids['year'] is not None: + meta['manual_year'] = ids['year'] + else: + ids = None + + if meta.get('category', None) == "MOVIE" and use_radarr and meta.get('tmdb_id', 0) != 0 and ids is None and not meta.get('matched_tracker', None): + ids = await get_radarr_data(tmdb_id=meta.get('tmdb_id', 0), debug=meta.get('debug', False)) + if ids: + if meta['debug']: + console.print(f"IMDB ID: {ids['imdb_id']}") + console.print(f"TMDB ID: {ids['tmdb_id']}") + console.print(f"Genres: {ids['genres']}") + console.print(f"Year: {ids['year']}") + console.print(f"Release Group: {ids['release_group']}") + if meta.get('imdb_id', 0) == 0 and ids['imdb_id'] is not None: + meta['imdb_id'] = ids['imdb_id'] + if meta.get('tmdb_id', 0) == 0 and ids['tmdb_id'] is not None: + meta['tmdb_id'] = ids['tmdb_id'] + if meta.get('manual_year', 0) == 0 and ids['year'] is not None: + meta['manual_year'] = ids['year'] + if meta.get('tag', None) is None: + meta['tag'] = ids['release_group'] + else: + ids = None + + # if there's no region/distributor info, lets ping some unit3d trackers and see if we get it + ping_unit3d_config = self.config['DEFAULT'].get('ping_unit3d', False) + if (not meta.get('region') or not meta.get('distributor')) and meta['is_disc'] == "BDMV" and ping_unit3d_config and not meta.get('edit', False) and not meta.get('emby', False): + await ping_unit3d(meta) + + # the first user override check that allows to set metadata ids. + # it relies on imdb or tvdb already being set. + user_overrides = config['DEFAULT'].get('user_overrides', False) + if user_overrides and (meta.get('imdb_id') != 0 or meta.get('tvdb_id') != 0) and not meta.get('emby', False): + meta = await get_source_override(meta, other_id=True) + meta['category'] = meta.get('category', None).upper() + # set a flag so that the other check later doesn't run + meta['no_override'] = True + + if meta.get('emby_cat', None) is not None and meta.get('emby_cat', None).upper() != meta.get('category', None): + return meta + if meta['debug']: + console.print("ID inputs into prep") + console.print("category:", meta.get("category")) + console.print(f"Raw TVDB ID: {meta['tvdb_id']} (type: {type(meta['tvdb_id']).__name__})") + console.print(f"Raw IMDb ID: {meta['imdb_id']} (type: {type(meta['imdb_id']).__name__})") + console.print(f"Raw TMDb ID: {meta['tmdb_id']} (type: {type(meta['tmdb_id']).__name__})") + console.print(f"Raw TVMAZE ID: {meta['tvmaze_id']} (type: {type(meta['tvmaze_id']).__name__})") + console.print(f"Raw MAL ID: {meta['mal_id']} (type: {type(meta['mal_id']).__name__})") + console.print("[yellow]Building meta data.....") - """ - Generate Screenshots - """ + # set a timer to check speed + if meta['debug']: + meta_middle_time = time.time() + console.print(f"Source/tracker data processed in {meta_middle_time - meta_start_time:.2f} seconds") - def disc_screenshots(self, filename, bdinfo, folder_id, base_dir, use_vs, image_list, ffdebug, num_screens=None): - if num_screens == None: - num_screens = self.screens - if num_screens == 0 or len(image_list) >= num_screens: - return - #Get longest m2ts - length = 0 - for each in bdinfo['files']: - int_length = sum(int(float(x)) * 60 ** i for i, x in enumerate(reversed(each['length'].split(':')))) - if int_length > length: - length = int_length - for root, dirs, files in os.walk(bdinfo['path']): - for name in files: - if name.lower() == each['file'].lower(): - file = f"{root}/{name}" - - - if "VC-1" in bdinfo['video'][0]['codec'] or bdinfo['video'][0]['hdr_dv'] != "": - keyframe = 'nokey' - else: - keyframe = 'none' + if meta.get('manual_language'): + meta['original_language'] = meta.get('manual_language').lower() - os.chdir(f"{base_dir}/tmp/{folder_id}") - i = len(glob.glob(f"{filename}-*.png")) - if i >= num_screens: - i = num_screens - console.print('[bold green]Reusing screenshots') - else: - console.print("[bold yellow]Saving Screens...") - if use_vs == True: - from src.vs import vs_screengn - vs_screengn(source=file, encode=None, filter_b_frames=False, num=num_screens, dir=f"{base_dir}/tmp/{folder_id}/") - else: - if bool(ffdebug) == True: - loglevel = 'verbose' - debug = False - else: - loglevel = 'quiet' - debug = True - with Progress( - TextColumn("[bold green]Saving Screens..."), - BarColumn(), - "[cyan]{task.completed}/{task.total}", - TimeRemainingColumn() - ) as progress: - screen_task = progress.add_task("[green]Saving Screens...", total=num_screens + 1) - ss_times = [] - for i in range(num_screens + 1): - image = f"{base_dir}/tmp/{folder_id}/{filename}-{i}.png" - try: - ss_times = self.valid_ss_time(ss_times, num_screens+1, length) - ( - ffmpeg - .input(file, ss=ss_times[-1], skip_frame=keyframe) - .output(image, vframes=1, pix_fmt="rgb24") - .overwrite_output() - .global_args('-loglevel', loglevel) - .run(quiet=debug) - ) - except Exception: - console.print(traceback.format_exc()) - - self.optimize_images(image) - if os.path.getsize(Path(image)) <= 31000000 and self.img_host == "imgbb": - i += 1 - elif os.path.getsize(Path(image)) <= 10000000 and self.img_host in ["imgbox", 'pixhost']: - i += 1 - elif os.path.getsize(Path(image)) <= 75000: - console.print("[bold yellow]Image is incredibly small, retaking") - time.sleep(1) - elif self.img_host == "ptpimg": - i += 1 - elif self.img_host == "lensdump": - i += 1 - else: - console.print("[red]Image too large for your image host, retaking") - time.sleep(1) - progress.advance(screen_task) - #remove smallest image - smallest = "" - smallestsize = 99 ** 99 - for screens in glob.glob1(f"{base_dir}/tmp/{folder_id}/", f"{filename}-*"): - screensize = os.path.getsize(screens) - if screensize < smallestsize: - smallestsize = screensize - smallest = screens - os.remove(smallest) - - def dvd_screenshots(self, meta, disc_num, num_screens=None): - if num_screens == None: - num_screens = self.screens - if num_screens == 0 or (len(meta.get('image_list', [])) >= num_screens and disc_num == 0): - return - ifo_mi = MediaInfo.parse(f"{meta['discs'][disc_num]['path']}/VTS_{meta['discs'][disc_num]['main_set'][0][:2]}_0.IFO", mediainfo_options={'inform_version' : '1'}) - sar = 1 - for track in ifo_mi.tracks: - if track.track_type == "Video": - length = float(track.duration)/1000 - par = float(track.pixel_aspect_ratio) - dar = float(track.display_aspect_ratio) - width = float(track.width) - height = float(track.height) - if par < 1: - # multiply that dar by the height and then do a simple width / height - new_height = dar * height - sar = width / new_height - w_sar = 1 - h_sar = sar - else: - sar = par - w_sar = sar - h_sar = 1 - - main_set_length = len(meta['discs'][disc_num]['main_set']) - if main_set_length >= 3: - main_set = meta['discs'][disc_num]['main_set'][1:-1] - elif main_set_length == 2: - main_set = meta['discs'][disc_num]['main_set'][1:] - elif main_set_length == 1: - main_set = meta['discs'][disc_num]['main_set'] - n = 0 - os.chdir(f"{meta['base_dir']}/tmp/{meta['uuid']}") - i = 0 - if len(glob.glob(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['discs'][disc_num]['name']}-*.png")) >= num_screens: - i = num_screens - console.print('[bold green]Reusing screenshots') - else: - if bool(meta.get('ffdebug', False)) == True: - loglevel = 'verbose' - debug = False - looped = 0 - retake = False - with Progress( - TextColumn("[bold green]Saving Screens..."), - BarColumn(), - "[cyan]{task.completed}/{task.total}", - TimeRemainingColumn() - ) as progress: - screen_task = progress.add_task("[green]Saving Screens...", total=num_screens + 1) - ss_times = [] - for i in range(num_screens + 1): - if n >= len(main_set): - n = 0 - if n >= num_screens: - n -= num_screens - image = f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['discs'][disc_num]['name']}-{i}.png" - if not os.path.exists(image) or retake != False: - retake = False - loglevel = 'quiet' - debug = True - if bool(meta.get('debug', False)): - loglevel = 'error' - debug = False - def _is_vob_good(n, loops, num_screens): - voblength = 300 - vob_mi = MediaInfo.parse(f"{meta['discs'][disc_num]['path']}/VTS_{main_set[n]}", output='JSON') - vob_mi = json.loads(vob_mi) - try: - voblength = float(vob_mi['media']['track'][1]['Duration']) - return voblength, n - except Exception: - try: - voblength = float(vob_mi['media']['track'][2]['Duration']) - return voblength, n - except Exception: - n += 1 - if n >= len(main_set): - n = 0 - if n >= num_screens: - n -= num_screens - if loops < 6: - loops = loops + 1 - voblength, n = _is_vob_good(n, loops, num_screens) - return voblength, n - else: - return 300, n - try: - voblength, n = _is_vob_good(n, 0, num_screens) - img_time = random.randint(round(voblength/5) , round(voblength - voblength/5)) - ss_times = self.valid_ss_time(ss_times, num_screens+1, voblength) - ff = ffmpeg.input(f"{meta['discs'][disc_num]['path']}/VTS_{main_set[n]}", ss=ss_times[-1]) - if w_sar != 1 or h_sar != 1: - ff = ff.filter('scale', int(round(width * w_sar)), int(round(height * h_sar))) - ( - ff - .output(image, vframes=1, pix_fmt="rgb24") - .overwrite_output() - .global_args('-loglevel', loglevel) - .run(quiet=debug) - ) - except Exception: - console.print(traceback.format_exc()) - self.optimize_images(image) - n += 1 - try: - if os.path.getsize(Path(image)) <= 31000000 and self.img_host == "imgbb": - i += 1 - elif os.path.getsize(Path(image)) <= 10000000 and self.img_host in ["imgbox", 'pixhost']: - i += 1 - elif os.path.getsize(Path(image)) <= 75000: - console.print("[yellow]Image is incredibly small (and is most likely to be a single color), retaking") - retake = True - time.sleep(1) - elif self.img_host == "ptpimg": - i += 1 - elif self.img_host == "lensdump": - i += 1 - else: - console.print("[red]Image too large for your image host, retaking") - retake = True - time.sleep(1) - looped = 0 - except Exception: - if looped >= 25: - console.print('[red]Failed to take screenshots') - exit() - looped += 1 - progress.advance(screen_task) - #remove smallest image - smallest = "" - smallestsize = 99**99 - for screens in glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}/", f"{meta['discs'][disc_num]['name']}-*"): - screensize = os.path.getsize(screens) - if screensize < smallestsize: - smallestsize = screensize - smallest = screens - os.remove(smallest) - - def screenshots(self, path, filename, folder_id, base_dir, meta, num_screens=None): - if num_screens == None: - num_screens = self.screens - len(meta.get('image_list', [])) - if num_screens == 0: - # or len(meta.get('image_list', [])) >= num_screens: - return - with open(f"{base_dir}/tmp/{folder_id}/MediaInfo.json", encoding='utf-8') as f: - mi = json.load(f) - video_track = mi['media']['track'][1] - length = video_track.get('Duration', mi['media']['track'][0]['Duration']) - width = float(video_track.get('Width')) - height = float(video_track.get('Height')) - par = float(video_track.get('PixelAspectRatio', 1)) - dar = float(video_track.get('DisplayAspectRatio')) - - if par == 1: - sar = w_sar = h_sar = 1 - elif par < 1: - new_height = dar * height - sar = width / new_height - w_sar = 1 - h_sar = sar - else: - sar = w_sar = par - h_sar = 1 - length = round(float(length)) - os.chdir(f"{base_dir}/tmp/{folder_id}") - i = 0 - if len(glob.glob(f"{filename}-*.png")) >= num_screens: - i = num_screens - console.print('[bold green]Reusing screenshots') - else: - loglevel = 'quiet' - debug = True - if bool(meta.get('ffdebug', False)) == True: - loglevel = 'verbose' - debug = False - if meta.get('vapoursynth', False) == True: - from src.vs import vs_screengn - vs_screengn(source=path, encode=None, filter_b_frames=False, num=num_screens, dir=f"{base_dir}/tmp/{folder_id}/") - else: - retake = False - with Progress( - TextColumn("[bold green]Saving Screens..."), - BarColumn(), - "[cyan]{task.completed}/{task.total}", - TimeRemainingColumn() - ) as progress: - ss_times = [] - screen_task = progress.add_task("[green]Saving Screens...", total=num_screens + 1) - for i in range(num_screens + 1): - image = os.path.abspath(f"{base_dir}/tmp/{folder_id}/{filename}-{i}.png") - if not os.path.exists(image) or retake != False: - retake = False - try: - ss_times = self.valid_ss_time(ss_times, num_screens+1, length) - ff = ffmpeg.input(path, ss=ss_times[-1]) - if w_sar != 1 or h_sar != 1: - ff = ff.filter('scale', int(round(width * w_sar)), int(round(height * h_sar))) - ( - ff - .output(image, vframes=1, pix_fmt="rgb24") - .overwrite_output() - .global_args('-loglevel', loglevel) - .run(quiet=debug) - ) - except Exception: - console.print(traceback.format_exc()) - - self.optimize_images(image) - if os.path.getsize(Path(image)) <= 75000: - console.print("[yellow]Image is incredibly small, retaking") - retake = True - time.sleep(1) - if os.path.getsize(Path(image)) <= 31000000 and self.img_host == "imgbb" and retake == False: - i += 1 - elif os.path.getsize(Path(image)) <= 10000000 and self.img_host in ["imgbox", 'pixhost'] and retake == False: - i += 1 - elif self.img_host in ["ptpimg", "lensdump"] and retake == False: - i += 1 - elif self.img_host == "freeimage.host": - console.print("[bold red]Support for freeimage.host has been removed. Please remove from your config") - exit() - elif retake == True: - pass - else: - console.print("[red]Image too large for your image host, retaking") - retake = True - time.sleep(1) - else: - i += 1 - progress.advance(screen_task) - #remove smallest image - smallest = "" - smallestsize = 99 ** 99 - for screens in glob.glob1(f"{base_dir}/tmp/{folder_id}/", f"{filename}-*"): - screensize = os.path.getsize(screens) - if screensize < smallestsize: - smallestsize = screensize - smallest = screens - os.remove(smallest) - - def valid_ss_time(self, ss_times, num_screens, length): - valid_time = False - while valid_time != True: - valid_time = True - if ss_times != []: - sst = random.randint(round(length/5), round(length/2)) - for each in ss_times: - tolerance = length / 10 / num_screens - if abs(sst - each) <= tolerance: - valid_time = False - if valid_time == True: - ss_times.append(sst) - else: - ss_times.append(random.randint(round(length/5), round(length/2))) - return ss_times + meta['type'] = await get_type(video, meta['scene'], meta['is_disc'], meta) - def optimize_images(self, image): - if self.config['DEFAULT'].get('optimize_images', True) == True: - if os.path.exists(image): - try: - pyver = platform.python_version_tuple() - if int(pyver[0]) == 3 and int(pyver[1]) >= 7: - import oxipng - if os.path.getsize(image) >= 31000000: - oxipng.optimize(image, level=6) - else: - oxipng.optimize(image, level=1) - except: - pass - return - """ - Get type and category - """ + # if it's not an anime, we can run season/episode checks now to speed the process + if meta.get("not_anime", False) and meta.get("category") == "TV": + meta = await get_season_episode(video, meta) - def get_type(self, video, scene, is_disc): - filename = os.path.basename(video).lower() - if "remux" in filename: - type = "REMUX" - elif any(word in filename for word in [" web ", ".web.", "web-dl"]): - type = "WEBDL" - elif "webrip" in filename: - type = "WEBRIP" - # elif scene == True: - # type = "ENCODE" - elif "hdtv" in filename: - type = "HDTV" - elif is_disc != None: - type = "DISC" - elif "dvdrip" in filename: - console.print("[bold red]DVDRip Detected, exiting") - exit() - else: - type = "ENCODE" - return type - - def get_cat(self, video): - # if category is None: - category = guessit(video.replace('1.0', ''))['type'] - if category.lower() == "movie": - category = "MOVIE" #1 - elif category.lower() in ("tv", "episode"): - category = "TV" #2 - else: - category = "MOVIE" - return category + # Run a check against mediainfo to see if it has tmdb/imdb + if (meta.get('tmdb_id') == 0 or meta.get('imdb_id') == 0) and not meta.get('emby', False): + meta['category'], meta['tmdb_id'], meta['imdb_id'], meta['tvdb_id'] = await get_tmdb_imdb_from_mediainfo( + mi, meta['category'], meta['is_disc'], meta['tmdb_id'], meta['imdb_id'], meta['tvdb_id'] + ) - async def get_tmdb_from_imdb(self, meta, filename): - if meta.get('tmdb_manual') != None: - meta['tmdb'] = meta['tmdb_manual'] - return meta - imdb_id = meta['imdb'] - if str(imdb_id)[:2].lower() != "tt": - imdb_id = f"tt{imdb_id}" - find = tmdb.Find(id=imdb_id) - info = find.info(external_source="imdb_id") - if len(info['movie_results']) >= 1: - meta['category'] = "MOVIE" - meta['tmdb'] = info['movie_results'][0]['id'] - elif len(info['tv_results']) >= 1: - meta['category'] = "TV" - meta['tmdb'] = info['tv_results'][0]['id'] - else: - imdb_info = await self.get_imdb_info(imdb_id.replace('tt', ''), meta) - title = imdb_info.get("title") - if title == None: - title = filename - year = imdb_info.get('year') - if year == None: - year = meta['search_year'] - console.print(f"[yellow]TMDb was unable to find anything with that IMDb, searching TMDb for {title}") - meta = await self.get_tmdb_id(title, year, meta, meta['category'], imdb_info.get('original title', imdb_info.get('localized title', meta['uuid']))) - if meta.get('tmdb') in ('None', '', None, 0, '0'): - if meta.get('mode', 'discord') == 'cli': - console.print('[yellow]Unable to find a matching TMDb entry') - tmdb_id = console.input("Please enter tmdb id: ") - parser = Args(config=self.config) - meta['category'], meta['tmdb'] = parser.parse_tmdb_id(id=tmdb_id, category=meta.get('category')) - await asyncio.sleep(2) - return meta + # Flag for emby if no IDs were found + if meta.get('imdb_id', 0) == 0 and meta.get('tvdb_id', 0) == 0 and meta.get('tmdb_id', 0) == 0 and meta.get('tvmaze_id', 0) == 0 and meta.get('mal_id', 0) == 0 and meta.get('emby', False): + meta['no_ids'] = True - async def get_tmdb_id(self, filename, search_year, meta, category, untouched_filename="", attempted=0): - search = tmdb.Search() - try: - if category == "MOVIE": - search.movie(query=filename, year=search_year) - elif category == "TV": - search.tv(query=filename, first_air_date_year=search_year) - if meta.get('tmdb_manual') != None: - meta['tmdb'] = meta['tmdb_manual'] - else: - meta['tmdb'] = search.results[0]['id'] - meta['category'] = category - except IndexError: - try: - if category == "MOVIE": - search.movie(query=filename) - elif category == "TV": - search.tv(query=filename) - meta['tmdb'] = search.results[0]['id'] - meta['category'] = category - except IndexError: - if category == "MOVIE": - category = "TV" - else: - category = "MOVIE" - if attempted <= 1: - attempted += 1 - meta = await self.get_tmdb_id(filename, search_year, meta, category, untouched_filename, attempted) - elif attempted == 2: - attempted += 1 - meta = await self.get_tmdb_id(anitopy.parse(guessit(untouched_filename, {"excludes" : ["country", "language"]})['title'])['anime_title'], search_year, meta, meta['category'], untouched_filename, attempted) - if meta['tmdb'] in (None, ""): - console.print(f"[red]Unable to find TMDb match for {filename}") - if meta.get('mode', 'discord') == 'cli': - tmdb_id = cli_ui.ask_string("Please enter tmdb id in this format: tv/12345 or movie/12345") - parser = Args(config=self.config) - meta['category'], meta['tmdb'] = parser.parse_tmdb_id(id=tmdb_id, category=meta.get('category')) - meta['tmdb_manual'] = meta['tmdb'] - return meta + meta['video_duration'] = await get_video_duration(meta) + duration = meta.get('video_duration', None) - return meta - - async def tmdb_other_meta(self, meta): - - if meta['tmdb'] == "0": - try: - title = guessit(meta['path'], {"excludes" : ["country", "language"]})['title'].lower() - title = title.split('aka')[0] - meta = await self.get_tmdb_id(guessit(title, {"excludes" : ["country", "language"]})['title'], meta['search_year'], meta) - if meta['tmdb'] == "0": - meta = await self.get_tmdb_id(title, "", meta, meta['category']) - except: - if meta.get('mode', 'discord') == 'cli': - console.print("[bold red]Unable to find tmdb entry. Exiting.") - exit() - else: - console.print("[bold red]Unable to find tmdb entry") - return meta - if meta['category'] == "MOVIE": - movie = tmdb.Movies(meta['tmdb']) - response = movie.info() - meta['title'] = response['title'] - if response['release_date']: - meta['year'] = datetime.strptime(response['release_date'],'%Y-%m-%d').year - else: - console.print('[yellow]TMDB does not have a release date, using year from filename instead (if it exists)') - meta['year'] = meta['search_year'] - external = movie.external_ids() - if meta.get('imdb', None) == None: - imdb_id = external.get('imdb_id', "0") - if imdb_id == "" or imdb_id == None: - meta['imdb_id'] = '0' - else: - meta['imdb_id'] = str(int(imdb_id.replace('tt', ''))).zfill(7) - else: - meta['imdb_id'] = str(meta['imdb']).replace('tt', '').zfill(7) - if meta.get('tvdb_id', '0') in ['', ' ', None, 'None', '0']: - meta['tvdb_id'] = external.get('tvdb_id', '0') - if meta['tvdb_id'] in ["", None, " ", "None"]: - meta['tvdb_id'] = '0' - try: - videos = movie.videos() - for each in videos.get('results', []): - if each.get('site', "") == 'YouTube' and each.get('type', "") == "Trailer": - meta['youtube'] = f"https://www.youtube.com/watch?v={each.get('key')}" - break - except Exception: - console.print('[yellow]Unable to grab videos from TMDb.') - - meta['aka'], original_language = await self.get_imdb_aka(meta['imdb_id']) - if original_language != None: - meta['original_language'] = original_language + # run a search to find tmdb and imdb ids if we don't have them + if meta.get('tmdb_id') == 0 and meta.get('imdb_id') == 0: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + unattended = False else: - meta['original_language'] = response['original_language'] - - meta['original_title'] = response.get('original_title', meta['title']) - meta['keywords'] = self.get_keywords(movie) - meta['genres'] = self.get_genres(response) - meta['tmdb_directors'] = self.get_directors(movie) - if meta.get('anime', False) == False: - meta['mal_id'], meta['aka'], meta['anime'] = self.get_anime(response, meta) - meta['poster'] = response.get('poster_path', "") - meta['overview'] = response['overview'] - meta['tmdb_type'] = 'Movie' - meta['runtime'] = response.get('episode_run_time', 60) - elif meta['category'] == "TV": - tv = tmdb.TV(meta['tmdb']) - response = tv.info() - meta['title'] = response['name'] - if response['first_air_date']: - meta['year'] = datetime.strptime(response['first_air_date'],'%Y-%m-%d').year + unattended = True + if meta.get('category') == "TV": + year = meta.get('manual_year', '') or meta.get('search_year', '') or meta.get('year', '') else: - console.print('[yellow]TMDB does not have a release date, using year from filename instead (if it exists)') - meta['year'] = meta['search_year'] - external = tv.external_ids() - if meta.get('imdb', None) == None: - imdb_id = external.get('imdb_id', "0") - if imdb_id == "" or imdb_id == None: - meta['imdb_id'] = '0' - else: - meta['imdb_id'] = str(int(imdb_id.replace('tt', ''))).zfill(7) - else: - meta['imdb_id'] = str(int(meta['imdb'].replace('tt', ''))).zfill(7) - if meta.get('tvdb_id', '0') in ['', ' ', None, 'None', '0']: - meta['tvdb_id'] = external.get('tvdb_id', '0') - if meta['tvdb_id'] in ["", None, " ", "None"]: - meta['tvdb_id'] = '0' + year = meta.get('manual_year', '') or meta.get('year', '') or meta.get('search_year', '') + tmdb_task = get_tmdb_id(filename, year, meta.get('category', None), untouched_filename, attempted=0, debug=meta['debug'], secondary_title=meta.get('secondary_title', None), path=meta.get('path', None), unattended=unattended) + imdb_task = search_imdb(filename, year, quickie=True, category=meta.get('category', None), debug=meta['debug'], secondary_title=meta.get('secondary_title', None), path=meta.get('path', None), untouched_filename=untouched_filename, duration=duration, unattended=unattended) + tmdb_result, imdb_result = await asyncio.gather(tmdb_task, imdb_task) + tmdb_id, category = tmdb_result + meta['category'] = category + meta['tmdb_id'] = int(tmdb_id) + meta['imdb_id'] = int(imdb_result) + meta['quickie_search'] = True + meta['no_ids'] = True + + # If we have an IMDb ID but no TMDb ID, fetch TMDb ID from IMDb + if meta.get('imdb_id') != 0 and meta.get('tmdb_id') == 0: + category, tmdb_id, original_language, filename_search = await get_tmdb_from_imdb( + meta['imdb_id'], + meta.get('tvdb_id'), + meta.get('search_year'), + filename, + debug=meta.get('debug', False), + mode=meta.get('mode', 'discord'), + category_preference=meta.get('category'), + imdb_info=meta.get('imdb_info', None) + ) + + meta['category'] = category + meta['tmdb_id'] = int(tmdb_id) + meta['original_language'] = original_language + meta['no_ids'] = filename_search + + # if we have all of the ids, search everything all at once + if int(meta['imdb_id']) != 0 and int(meta['tvdb_id']) != 0 and int(meta['tmdb_id']) != 0 and int(meta['tvmaze_id']) != 0: + meta = await all_ids(meta, tvdb_api, tvdb_token) + + # Check if IMDb, TMDb, and TVDb IDs are all present + elif int(meta['imdb_id']) != 0 and int(meta['tvdb_id']) != 0 and int(meta['tmdb_id']) != 0 and not meta.get('quickie_search', False): + meta = await imdb_tmdb_tvdb(meta, filename, tvdb_api, tvdb_token) + + # Check if both IMDb and TVDB IDs are present + elif int(meta['imdb_id']) != 0 and int(meta['tvdb_id']) != 0 and not meta.get('quickie_search', False): + meta = await imdb_tvdb(meta, filename, tvdb_api, tvdb_token) + + # Check if both IMDb and TMDb IDs are present + elif int(meta['imdb_id']) != 0 and int(meta['tmdb_id']) != 0 and not meta.get('quickie_search', False): + meta = await imdb_tmdb(meta, filename) + + # we should have tmdb id one way or another, so lets get data if needed + if int(meta['tmdb_id']) != 0: + await set_tmdb_metadata(meta, filename) + + # If there's a mismatch between IMDb and TMDb IDs, try to resolve it + if meta.get('imdb_mismatch', False) and "subsplease" not in meta.get('uuid', '').lower(): + if meta['debug']: + console.print("[yellow]IMDb ID mismatch detected, attempting to resolve...[/yellow]") + # with refactored tmdb, it quite likely to be correct + meta['imdb_id'] = meta.get('mismatched_imdb_id', 0) + meta['imdb_info'] = None + + # Get IMDb ID if not set + if meta.get('imdb_id') == 0: + meta['imdb_id'] = await search_imdb(filename, meta['search_year'], quickie=False, category=meta.get('category', None), debug=meta.get('debug', False)) + + # user might have skipped tmdb earlier, lets double check + if meta.get('imdb_id') != 0 and meta.get('tmdb_id') == 0: + console.print("[yellow]No TMDB ID found, attempting to fetch from IMDb...[/yellow]") + category, tmdb_id, original_language, filename_search = await get_tmdb_from_imdb( + meta['imdb_id'], + meta.get('tvdb_id'), + meta.get('search_year'), + filename, + debug=meta.get('debug', False), + mode=meta.get('mode', 'discord'), + category_preference=meta.get('category'), + imdb_info=meta.get('imdb_info', None) + ) + + meta['category'] = category + meta['tmdb_id'] = int(tmdb_id) + meta['original_language'] = original_language + meta['no_ids'] = filename_search + + if int(meta['tmdb_id']) != 0: + await set_tmdb_metadata(meta, filename) + + # Ensure IMDb info is retrieved if it wasn't already fetched + if meta.get('imdb_info', None) is None and int(meta['imdb_id']) != 0: + imdb_info = await get_imdb_info_api(meta['imdb_id'], manual_language=meta.get('manual_language'), debug=meta.get('debug', False)) + meta['imdb_info'] = imdb_info + meta['tv_year'] = imdb_info.get('tv_year', None) + check_valid_data = meta.get('imdb_info', {}).get('title', "") + if check_valid_data: try: - videos = tv.videos() - for each in videos.get('results', []): - if each.get('site', "") == 'YouTube' and each.get('type', "") == "Trailer": - meta['youtube'] = f"https://www.youtube.com/watch?v={each.get('key')}" - break - except Exception: - console.print('[yellow]Unable to grab videos from TMDb.') + title = meta['title'].lower().strip() + except KeyError: + console.print("[red]Title is missing from TMDB....") + sys.exit(1) + aka = meta.get('imdb_info', {}).get('title', "").strip().lower() + imdb_aka = meta.get('imdb_info', {}).get('aka', "").strip().lower() + year = str(meta.get('imdb_info', {}).get('year', "")) + + if aka and not meta.get('aka'): + aka_trimmed = aka[4:].strip().lower() if aka.lower().startswith("aka") else aka.lower() + difference = SequenceMatcher(None, title, aka_trimmed).ratio() + if difference >= 0.7 or not aka_trimmed or aka_trimmed in title: + aka = None + + difference = SequenceMatcher(None, title, imdb_aka).ratio() + if difference >= 0.7 or not imdb_aka or imdb_aka in title: + imdb_aka = None + + if aka is not None: + if f"({year})" in aka: + aka = meta.get('imdb_info', {}).get('title', "").replace(f"({year})", "").strip() + else: + aka = meta.get('imdb_info', {}).get('title', "").strip() + meta['aka'] = f"AKA {aka.strip()}" + meta['title'] = meta['title'].strip() + elif imdb_aka is not None: + if f"({year})" in imdb_aka: + imdb_aka = meta.get('imdb_info', {}).get('aka', "").replace(f"({year})", "").strip() + else: + imdb_aka = meta.get('imdb_info', {}).get('aka', "").strip() + meta['aka'] = f"AKA {imdb_aka.strip()}" + meta['title'] = meta['title'].strip() - # meta['aka'] = f" AKA {response['original_name']}" - meta['aka'], original_language = await self.get_imdb_aka(meta['imdb_id']) - if original_language != None: - meta['original_language'] = original_language - else: - meta['original_language'] = response['original_language'] - meta['original_title'] = response.get('original_name', meta['title']) - meta['keywords'] = self.get_keywords(tv) - meta['genres'] = self.get_genres(response) - meta['tmdb_directors'] = self.get_directors(tv) - meta['mal_id'], meta['aka'], meta['anime'] = self.get_anime(response, meta) - meta['poster'] = response.get('poster_path', '') - meta['overview'] = response['overview'] - - meta['tmdb_type'] = response.get('type', 'Scripted') - runtime = response.get('episode_run_time', [60]) - if runtime == []: - runtime = [60] - meta['runtime'] = runtime[0] - if meta['poster'] not in (None, ''): - meta['poster'] = f"https://image.tmdb.org/t/p/original{meta['poster']}" - - difference = SequenceMatcher(None, meta['title'].lower(), meta['aka'][5:].lower()).ratio() - if difference >= 0.9 or meta['aka'][5:].strip() == "" or meta['aka'][5:].strip().lower() in meta['title'].lower(): + if meta.get('aka', None) is None: meta['aka'] = "" - if f"({meta['year']})" in meta['aka']: - meta['aka'] = meta['aka'].replace(f"({meta['year']})", "").strip() - - - return meta - + if meta['category'] == "TV": + if meta.get('tvmaze_id', 0) == 0 and meta.get('tvdb_id', 0) == 0: + await get_tvmaze_tvdb(meta, filename, tvdb_api, tvdb_token) + elif meta.get('tvmaze_id', 0) == 0: + meta['tvmaze_id'], meta['imdb_id'], meta['tvdb_id'] = await search_tvmaze( + filename, meta['search_year'], meta.get('imdb_id', 0), meta.get('tvdb_id', 0), + manual_date=meta.get('manual_date'), + tvmaze_manual=meta.get('tvmaze_manual'), + debug=meta.get('debug', False), + return_full_tuple=True + ) + else: + meta.setdefault('tvmaze_id', 0) + if meta.get('tvdb_id', 0) == 0 and tvdb_api and tvdb_token: + meta['tvdb_id'] = await get_tvdb_series(base_dir, filename, year=meta.get('year', ''), apikey=tvdb_api, token=tvdb_token, debug=meta.get('debug', False)) + + # if it was skipped earlier, make sure we have the season/episode data + if not meta.get('not_anime', False): + meta = await get_season_episode(video, meta) + # all your episode data belongs to us + meta = await get_tv_data(meta, base_dir, tvdb_api, tvdb_token) + + if meta.get('tvdb_episode_data', None) and meta.get('tvdb_episode_data').get('imdb_id', None): + imdb = meta.get('tvdb_episode_data').get('imdb_id', 0).replace('tt', '') + if imdb.isdigit(): + if imdb != meta.get('imdb_id', 0): + episode_info = await get_imdb_from_episode(imdb, debug=True) + if episode_info: + series_id = episode_info.get('series', {}).get('series_id', None) + if series_id: + series_imdb = series_id.replace('tt', '') + if series_imdb.isdigit() and int(series_imdb) != meta.get('imdb_id', 0): + if meta['debug']: + console.print(f"[yellow]Updating IMDb ID from episode data: {series_imdb}") + meta['imdb_id'] = int(series_imdb) + imdb_info = await get_imdb_info_api(meta['imdb_id'], manual_language=meta.get('manual_language'), debug=meta.get('debug', False)) + meta['imdb_info'] = imdb_info + meta['tv_year'] = imdb_info.get('year', None) + check_valid_data = meta.get('imdb_info', {}).get('title', "") + if check_valid_data: + title = meta.get('title', "").strip() + aka = meta.get('imdb_info', {}).get('aka', "").strip() + year = str(meta.get('imdb_info', {}).get('year', "")) + + if aka: + aka_trimmed = aka[4:].strip().lower() if aka.lower().startswith("aka") else aka.lower() + difference = SequenceMatcher(None, title.lower(), aka_trimmed).ratio() + if difference >= 0.7 or not aka_trimmed or aka_trimmed in title: + aka = None + + if aka is not None: + if f"({year})" in aka: + aka = meta.get('imdb_info', {}).get('aka', "").replace(f"({year})", "").strip() + else: + aka = meta.get('imdb_info', {}).get('aka', "").strip() + meta['aka'] = f"AKA {aka.strip()}" + else: + meta['aka'] = "" + else: + meta['aka'] = "" + + # if we're using tvdb, lets use it's series name if it applies + # language check since tvdb returns original language names + if tvdb_api and tvdb_token and meta.get('original_language', "") == "en": + if meta.get('tvdb_episode_data'): + series_name = meta['tvdb_episode_data'].get('series_name', '') + if series_name and meta.get('title') != series_name: + if meta['debug']: + console.print(f"[yellow]tvdb series name: {series_name}") + year_match = re.search(r'\b(19|20)\d{2}\b', series_name) + if year_match: + extracted_year = year_match.group(0) + meta['search_year'] = extracted_year + series_name = re.sub(r'\s*\b(19|20)\d{2}\b\s*', '', series_name).strip() + series_name = series_name.replace('(', '').replace(')', '').strip() + if series_name: # Only set if not empty + meta['title'] = series_name + elif meta.get('tvdb_series_name'): + series_name = meta.get('tvdb_series_name') + if series_name and meta.get('title') != series_name: + if meta['debug']: + console.print(f"[yellow]tvdb series name: {series_name}") + year_match = re.search(r'\b(19|20)\d{2}\b', series_name) + if year_match: + extracted_year = year_match.group(0) + meta['search_year'] = extracted_year + series_name = re.sub(r'\s*\b(19|20)\d{2}\b\s*', '', series_name).strip() + series_name = series_name.replace('(', '').replace(')', '').strip() + if series_name: # Only set if not empty + meta['title'] = series_name + + # bluray.com data if config + get_bluray_info = self.config['DEFAULT'].get('get_bluray_info', False) + meta['bluray_score'] = int(float(self.config['DEFAULT'].get('bluray_score', 100))) + meta['bluray_single_score'] = int(float(self.config['DEFAULT'].get('bluray_single_score', 100))) + meta['use_bluray_images'] = self.config['DEFAULT'].get('use_bluray_images', False) + if meta.get('is_disc') in ("BDMV", "DVD") and get_bluray_info and (meta.get('distributor') is None or meta.get('region') is None) and meta.get('imdb_id') != 0 and not meta.get('emby', False): + await get_bluray_releases(meta) + + # and if we getting bluray/dvd images, we'll rehost them + if meta.get('is_disc') in ("BDMV", "DVD") and meta.get('use_bluray_images', False): + from src.rehostimages import check_hosts + url_host_mapping = { + "ibb.co": "imgbb", + "pixhost.to": "pixhost", + "imgbox.com": "imgbox", + } - def get_keywords(self, tmdb_info): - if tmdb_info is not None: - tmdb_keywords = tmdb_info.keywords() - if tmdb_keywords.get('keywords') is not None: - keywords=[f"{keyword['name'].replace(',',' ')}" for keyword in tmdb_keywords.get('keywords')] - elif tmdb_keywords.get('results') is not None: - keywords=[f"{keyword['name'].replace(',',' ')}" for keyword in tmdb_keywords.get('results')] - return(', '.join(keywords)) - else: - return '' - - def get_genres(self, tmdb_info): - if tmdb_info is not None: - tmdb_genres = tmdb_info.get('genres', []) - if tmdb_genres is not []: - genres=[f"{genre['name'].replace(',',' ')}" for genre in tmdb_genres] - return(', '.join(genres)) - else: - return '' - - def get_directors(self, tmdb_info): - if tmdb_info is not None: - tmdb_credits = tmdb_info.credits() - directors = [] - if tmdb_credits.get('cast', []) != []: - for each in tmdb_credits['cast']: - if each.get('known_for_department', '') == "Directing": - directors.append(each.get('original_name', each.get('name'))) - return directors - else: - return '' + approved_image_hosts = ['imgbox', 'imgbb', 'pixhost'] + await check_hosts(meta, "covers", url_host_mapping=url_host_mapping, img_host_index=1, approved_image_hosts=approved_image_hosts) - def get_anime(self, response, meta): - tmdb_name = meta['title'] - if meta.get('aka', "") == "": - alt_name = "" - else: - alt_name = meta['aka'] - anime = False - animation = False - for each in response['genres']: - if each['id'] == 16: - animation = True - if response['original_language'] == 'ja' and animation == True: - romaji, mal_id, eng_title, season_year, episodes = self.get_romaji(tmdb_name, meta.get('mal', None)) - alt_name = f" AKA {romaji}" - - anime = True - # mal = AnimeSearch(romaji) - # mal_id = mal.results[0].mal_id - else: - mal_id = 0 - if meta.get('mal_id', 0) != 0: - mal_id = meta.get('mal_id') - if meta.get('mal') not in ('0', 0, None): - mal_id = meta.get('mal', 0) - return mal_id, alt_name, anime - - def get_romaji(self, tmdb_name, mal): - if mal == None: - mal = 0 - tmdb_name = tmdb_name.replace('-', "").replace("The Movie", "") - tmdb_name = ' '.join(tmdb_name.split()) - query = ''' - query ($search: String) { - Page (page: 1) { - pageInfo { - total - } - media (search: $search, type: ANIME, sort: SEARCH_MATCH) { - id - idMal - title { - romaji - english - native - } - seasonYear - episodes - } - } - } - ''' - # Define our query variables and values that will be used in the query request - variables = { - 'search': tmdb_name - } - else: - query = ''' - query ($search: Int) { - Page (page: 1) { - pageInfo { - total - } - media (idMal: $search, type: ANIME, sort: SEARCH_MATCH) { - id - idMal - title { - romaji - english - native - } - seasonYear - episodes - } - } - } - ''' - # Define our query variables and values that will be used in the query request - variables = { - 'search': mal - } - - # Make the HTTP Api request - url = 'https://graphql.anilist.co' - try: - response = requests.post(url, json={'query': query, 'variables': variables}) - json = response.json() - media = json['data']['Page']['media'] - except: - console.print('[red]Failed to get anime specific info from anilist. Continuing without it...') - media = [] - if media not in (None, []): - result = {'title' : {}} - difference = 0 - for anime in media: - search_name = re.sub("[^0-9a-zA-Z\[\]]+", "", tmdb_name.lower().replace(' ', '')) - for title in anime['title'].values(): - if title != None: - title = re.sub(u'[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf]+ (?=[A-Za-z ]+–)', "", title.lower().replace(' ', ''), re.U) - diff = SequenceMatcher(None, title, search_name).ratio() - if diff >= difference: - result = anime - difference = diff - - romaji = result['title'].get('romaji', result['title'].get('english', "")) - mal_id = result.get('idMal', 0) - eng_title = result['title'].get('english', result['title'].get('romaji', "")) - season_year = result.get('season_year', "") - episodes = result.get('episodes', 0) - else: - romaji = eng_title = season_year = "" - episodes = mal_id = 0 - if mal_id in [None, 0]: - mal_id = mal - if not episodes: - episodes = 0 - return romaji, mal_id, eng_title, season_year, episodes + # user override check that only sets data after metadata setting + if user_overrides and not meta.get('no_override', False) and not meta.get('emby', False): + meta = await get_source_override(meta) + meta['video'] = video + if not meta.get('emby', False): + meta['container'] = await get_container(meta) + meta['audio'], meta['channels'], meta['has_commentary'] = await get_audio_v2(mi, meta, bdinfo) + meta['3D'] = await is_3d(mi, bdinfo) + meta['source'], meta['type'] = await get_source(meta['type'], video, meta['path'], meta['is_disc'], meta, folder_id, base_dir) + meta['uhd'] = await get_uhd(meta['type'], guessit(meta['path']), meta['resolution'], meta['path']) + meta['hdr'] = await get_hdr(mi, bdinfo) + meta['distributor'] = await get_distributor(meta['distributor']) - """ - Mediainfo/Bdinfo > meta - """ - def get_audio_v2(self, mi, meta, bdinfo): - extra = dual = "" - has_commentary = False - #Get formats - if bdinfo != None: #Disks - format_settings = "" - format = bdinfo['audio'][0]['codec'] - commercial = format - try: - additional = bdinfo['audio'][0]['atmos_why_you_be_like_this'] - except: - additional = "" - #Channels - chan = bdinfo['audio'][0]['channels'] - - - else: - track_num = 2 - for i in range(len(mi['media']['track'])): - t = mi['media']['track'][i] - if t['@type'] != "Audio": - pass - else: - if t.get('Language', "") == meta['original_language'] and "commentary" not in t.get('Title', '').lower(): - track_num = i - break - format = mi['media']['track'][track_num]['Format'] - commercial = mi['media']['track'][track_num].get('Format_Commercial', '') - if mi['media']['track'][track_num].get('Language', '') == "zxx": - meta['silent'] = True - try: - additional = mi['media']['track'][track_num]['Format_AdditionalFeatures'] - # format = f"{format} {additional}" - except: - additional = "" - try: - format_settings = mi['media']['track'][track_num]['Format_Settings'] - if format_settings in ['Explicit']: - format_settings = "" - except: - format_settings = "" - #Channels - channels = mi['media']['track'][track_num].get('Channels_Original', mi['media']['track'][track_num]['Channels']) - if not str(channels).isnumeric(): - channels = mi['media']['track'][track_num]['Channels'] - try: - channel_layout = mi['media']['track'][track_num]['ChannelLayout'] - except: - try: - channel_layout = mi['media']['track'][track_num]['ChannelLayout_Original'] - except: - channel_layout = "" - if "LFE" in channel_layout: - chan = f"{int(channels) - 1}.1" - elif channel_layout == "": - if int(channels) <= 2: - chan = f"{int(channels)}.0" - else: - chan = f"{int(channels) - 1}.1" + if meta.get('is_disc', None) == "BDMV": # Blu-ray Specific + meta['region'] = await get_region(bdinfo, meta.get('region', None)) + meta['video_codec'] = await get_video_codec(bdinfo) else: - chan = f"{channels}.0" - - if meta['original_language'] != 'en': - eng, orig = False, False - try: - for t in mi['media']['track']: - if t['@type'] != "Audio": - pass - else: - audio_language = t.get('Language', '') - # Check for English Language Track - if audio_language == "en" and "commentary" not in t.get('Title', '').lower(): - eng = True - # Check for original Language Track - if audio_language == meta['original_language'] and "commentary" not in t.get('Title', '').lower(): - orig = True - # Catch Chinese / Norwegian variants - variants = ['zh', 'cn', 'cmn', 'no', 'nb'] - if audio_language in variants and meta['original_language'] in variants: - orig = True - # Check for additional, bloated Tracks - if audio_language != meta['original_language'] and audio_language != "en": - if meta['original_language'] not in variants and audio_language not in variants: - audio_language = "und" if audio_language == "" else audio_language - console.print(f"[bold red]This release has a(n) {audio_language} audio track, and may be considered bloated") - time.sleep(5) - if eng and orig == True: - dual = "Dual-Audio" - elif eng == True and orig == False and meta['original_language'] not in ['zxx', 'xx', None] and meta.get('no_dub', False) == False: - dual = "Dubbed" - except Exception: - console.print(traceback.print_exc()) - pass - - - for t in mi['media']['track']: - if t['@type'] != "Audio": - pass - else: - if "commentary" in t.get('Title', '').lower(): - has_commentary = True - - - #Convert commercial name to naming conventions - audio = { - #Format - "DTS": "DTS", - "AAC": "AAC", - "AAC LC": "AAC", - "AC-3": "DD", - "E-AC-3": "DD+", - "MLP FBA": "TrueHD", - "FLAC": "FLAC", - "Opus": "OPUS", - "Vorbis": "VORBIS", - "PCM": "LPCM", - - #BDINFO AUDIOS - "LPCM Audio" : "LPCM", - "Dolby Digital Audio" : "DD", - "Dolby Digital Plus Audio" : "DD+", - # "Dolby TrueHD" : "TrueHD", - "Dolby TrueHD Audio" : "TrueHD", - "DTS Audio" : "DTS", - "DTS-HD Master Audio" : "DTS-HD MA", - "DTS-HD High-Res Audio" : "DTS-HD HRA", - "DTS:X Master Audio" : "DTS:X" - } - audio_extra = { - "XLL": "-HD MA", - "XLL X": ":X", - "ES": "-ES", - } - format_extra = { - "JOC": " Atmos", - "16-ch": " Atmos", - "Atmos Audio": " Atmos", - } - format_settings_extra = { - "Dolby Surround EX" : "EX" - } - - commercial_names = { - "Dolby Digital" : "DD", - "Dolby Digital Plus" : "DD+", - "Dolby TrueHD" : "TrueHD", - "DTS-ES" : "DTS-ES", - "DTS-HD High" : "DTS-HD HRA", - "Free Lossless Audio Codec" : "FLAC", - "DTS-HD Master Audio" : "DTS-HD MA" - } - - - search_format = True - for key, value in commercial_names.items(): - if key in commercial: - codec = value - search_format = False - if "Atmos" in commercial or format_extra.get(additional, "") == " Atmos": - extra = " Atmos" - if search_format: - codec = audio.get(format, "") + audio_extra.get(additional, "") - extra = format_extra.get(additional, "") - format_settings = format_settings_extra.get(format_settings, "") - if format_settings == "EX" and chan == "5.1": - format_settings = "EX" - else: - format_settings = "" - - if codec == "": - codec = format - - if format.startswith("DTS"): - if additional.endswith("X"): - codec = "DTS:X" - chan = f"{int(channels) - 1}.1" - if format == "MPEG Audio": - codec = mi['media']['track'][2].get('CodecID_Hint', '') + meta['video_encode'], meta['video_codec'], meta['has_encode_settings'], meta['bit_depth'] = await get_video_encode(mi, meta['type'], bdinfo) - + if meta.get('no_edition') is False: + meta['edition'], meta['repack'], meta['webdv'] = await get_edition(meta['uuid'], bdinfo, meta['filelist'], meta.get('manual_edition'), meta) + if "REPACK" in meta.get('edition', ""): + meta['repack'] = re.search(r"REPACK[\d]?", meta['edition'])[0] + meta['edition'] = re.sub(r"REPACK[\d]?", "", meta['edition']).strip().replace(' ', ' ') + else: + meta['edition'] = "" - audio = f"{dual} {codec} {format_settings} {chan}{extra}" - audio = ' '.join(audio.split()) - return audio, chan, has_commentary + meta.get('stream', False) + meta['stream'] = await self.stream_optimized(meta['stream']) + if meta.get('tag', None) is None: + if meta.get('we_need_tag', False): + meta['tag'] = await get_tag(meta['scene_name'], meta) + else: + meta['tag'] = await get_tag(video, meta) + # all lowercase filenames will have bad group tag, it's probably a scene release. + # some extracted files do not match release name so lets double check if it really is a scene release + if not meta.get('scene') and meta['tag']: + base = os.path.basename(video) + match = re.match(r"^(.+)\.[a-zA-Z0-9]{3}$", os.path.basename(video)) + if match and (not meta['is_disc'] or meta.get('keep_folder', False)): + base = match.group(1) + is_all_lowercase = base.islower() + if is_all_lowercase: + release_name = await is_scene(videopath, meta, meta.get('imdb_id', 0), lower=True) + if release_name is not None: + try: + meta['scene_name'] = release_name + meta['tag'] = await self.get_tag(release_name, meta) + except Exception: + console.print("[red]Error getting tag from scene name, check group tag.[/red]") - def is_3d(self, mi, bdinfo): - if bdinfo != None: - if bdinfo['video'][0]['3d'] != "": - return "3D" else: - return "" - else: - return "" - - def get_tag(self, video, meta): - try: - tag = guessit(video)['release_group'] - tag = f"-{tag}" - except: - tag = "" - if tag == "-": - tag = "" - if tag[1:].lower() in ["nogroup", 'nogrp']: - tag = "" - return tag - - - def get_source(self, type, video, path, is_disc, meta): - try: - try: - source = guessit(video)['source'] - except: - try: - source = guessit(path)['source'] - except: - source = "BluRay" - if meta.get('manual_source', None): - source = meta['manual_source'] - if source in ("Blu-ray", "Ultra HD Blu-ray", "BluRay", "BR") or is_disc == "BDMV": - if type == "DISC": - source = "Blu-ray" - elif type in ('ENCODE', 'REMUX'): - source = "BluRay" - if is_disc == "DVD" or source in ("DVD", "dvd"): - try: - if is_disc == "DVD": - mediainfo = MediaInfo.parse(f"{meta['discs'][0]['path']}/VTS_{meta['discs'][0]['main_set'][0][:2]}_0.IFO") - else: - mediainfo = MediaInfo.parse(video) - for track in mediainfo.tracks: - if track.track_type == "Video": - system = track.standard - if system not in ("PAL", "NTSC"): - raise WeirdSystem - except: - try: - other = guessit(video)['other'] - if "PAL" in other: - system = "PAL" - elif "NTSC" in other: - system = "NTSC" - except: - system = "" - finally: - if system == None: - system = "" - if type == "REMUX": - system = f"{system} DVD".strip() - source = system - if source in ("Web", "WEB"): - if type == "ENCODE": - type = "WEBRIP" - if source in ("HD-DVD", "HD DVD", "HDDVD"): - if is_disc == "HDDVD": - source = "HD DVD" - if type in ("ENCODE", "REMUX"): - source = "HDDVD" - if type in ("WEBDL", 'WEBRIP'): - source = "Web" - if source == "Ultra HDTV": - source = "UHDTV" - except Exception: - console.print(traceback.format_exc()) - source = "BluRay" - - return source, type - - def get_uhd(self, type, guess, resolution, path): - try: - source = guess['Source'] - other = guess['Other'] - except: - source = "" - other = "" - uhd = "" - if source == 'Blu-ray' and other == "Ultra HD" or source == "Ultra HD Blu-ray": - uhd = "UHD" - elif "UHD" in path: - uhd = "UHD" - elif type in ("DISC", "REMUX", "ENCODE", "WEBRIP"): - uhd = "" - - if type in ("DISC", "REMUX", "ENCODE") and resolution == "2160p": - uhd = "UHD" - - return uhd - - def get_hdr(self, mi, bdinfo): - hdr = "" - dv = "" - if bdinfo != None: #Disks - hdr_mi = bdinfo['video'][0]['hdr_dv'] - if "HDR10+" in hdr_mi: - hdr = "HDR10+" - elif hdr_mi == "HDR10": - hdr = "HDR" - try: - if bdinfo['video'][1]['hdr_dv'] == "Dolby Vision": - dv = "DV" - except: - pass - else: - video_track = mi['media']['track'][1] - try: - hdr_mi = video_track['colour_primaries'] - if hdr_mi in ("BT.2020", "REC.2020"): - hdr = "" - hdr_format_string = video_track.get('HDR_Format_Compatibility', video_track.get('HDR_Format_String', video_track.get('HDR_Format', ""))) - if "HDR10" in hdr_format_string: - hdr = "HDR" - if "HDR10+" in hdr_format_string: - hdr = "HDR10+" - if hdr_format_string == "" and "PQ" in (video_track.get('transfer_characteristics'), video_track.get('transfer_characteristics_Original', None)): - hdr = "PQ10" - transfer_characteristics = video_track.get('transfer_characteristics_Original', None) - if "HLG" in transfer_characteristics: - hdr = "HLG" - if hdr != "HLG" and "BT.2020 (10-bit)" in transfer_characteristics: - hdr = "WCG" - except: - pass - - try: - if "Dolby Vision" in video_track.get('HDR_Format', '') or "Dolby Vision" in video_track.get('HDR_Format_String', ''): - dv = "DV" - except: - pass - - hdr = f"{dv} {hdr}".strip() - return hdr - - def get_region(self, bdinfo, region=None): - label = bdinfo.get('label', bdinfo.get('title', bdinfo.get('path', ''))).replace('.', ' ') - if region != None: - region = region.upper() - else: - regions = { - 'AFG': 'AFG', 'AIA': 'AIA', 'ALA': 'ALA', 'ALG': 'ALG', 'AND': 'AND', 'ANG': 'ANG', 'ARG': 'ARG', - 'ARM': 'ARM', 'ARU': 'ARU', 'ASA': 'ASA', 'ATA': 'ATA', 'ATF': 'ATF', 'ATG': 'ATG', 'AUS': 'AUS', - 'AUT': 'AUT', 'AZE': 'AZE', 'BAH': 'BAH', 'BAN': 'BAN', 'BDI': 'BDI', 'BEL': 'BEL', 'BEN': 'BEN', - 'BER': 'BER', 'BES': 'BES', 'BFA': 'BFA', 'BHR': 'BHR', 'BHU': 'BHU', 'BIH': 'BIH', 'BLM': 'BLM', - 'BLR': 'BLR', 'BLZ': 'BLZ', 'BOL': 'BOL', 'BOT': 'BOT', 'BRA': 'BRA', 'BRB': 'BRB', 'BRU': 'BRU', - 'BVT': 'BVT', 'CAM': 'CAM', 'CAN': 'CAN', 'CAY': 'CAY', 'CCK': 'CCK', 'CEE': 'CEE', 'CGO': 'CGO', - 'CHA': 'CHA', 'CHI': 'CHI', 'CHN': 'CHN', 'CIV': 'CIV', 'CMR': 'CMR', 'COD': 'COD', 'COK': 'COK', - 'COL': 'COL', 'COM': 'COM', 'CPV': 'CPV', 'CRC': 'CRC', 'CRO': 'CRO', 'CTA': 'CTA', 'CUB': 'CUB', - 'CUW': 'CUW', 'CXR': 'CXR', 'CYP': 'CYP', 'DJI': 'DJI', 'DMA': 'DMA', 'DOM': 'DOM', 'ECU': 'ECU', - 'EGY': 'EGY', 'ENG': 'ENG', 'EQG': 'EQG', 'ERI': 'ERI', 'ESH': 'ESH', 'ESP': 'ESP', 'ETH': 'ETH', - 'FIJ': 'FIJ', 'FLK': 'FLK', 'FRA': 'FRA', 'FRO': 'FRO', 'FSM': 'FSM', 'GAB': 'GAB', 'GAM': 'GAM', - 'GBR': 'GBR', 'GEO': 'GEO', 'GER': 'GER', 'GGY': 'GGY', 'GHA': 'GHA', 'GIB': 'GIB', 'GLP': 'GLP', - 'GNB': 'GNB', 'GRE': 'GRE', 'GRL': 'GRL', 'GRN': 'GRN', 'GUA': 'GUA', 'GUF': 'GUF', 'GUI': 'GUI', - 'GUM': 'GUM', 'GUY': 'GUY', 'HAI': 'HAI', 'HKG': 'HKG', 'HMD': 'HMD', 'HON': 'HON', 'HUN': 'HUN', - 'IDN': 'IDN', 'IMN': 'IMN', 'IND': 'IND', 'IOT': 'IOT', 'IRL': 'IRL', 'IRN': 'IRN', 'IRQ': 'IRQ', - 'ISL': 'ISL', 'ISR': 'ISR', 'ITA': 'ITA', 'JAM': 'JAM', 'JEY': 'JEY', 'JOR': 'JOR', 'JPN': 'JPN', - 'KAZ': 'KAZ', 'KEN': 'KEN', 'KGZ': 'KGZ', 'KIR': 'KIR', 'KNA': 'KNA', 'KOR': 'KOR', 'KSA': 'KSA', - 'KUW': 'KUW', 'KVX': 'KVX', 'LAO': 'LAO', 'LBN': 'LBN', 'LBR': 'LBR', 'LBY': 'LBY', 'LCA': 'LCA', - 'LES': 'LES', 'LIE': 'LIE', 'LKA': 'LKA', 'LUX': 'LUX', 'MAC': 'MAC', 'MAD': 'MAD', 'MAF': 'MAF', - 'MAR': 'MAR', 'MAS': 'MAS', 'MDA': 'MDA', 'MDV': 'MDV', 'MEX': 'MEX', 'MHL': 'MHL', 'MKD': 'MKD', - 'MLI': 'MLI', 'MLT': 'MLT', 'MNG': 'MNG', 'MNP': 'MNP', 'MON': 'MON', 'MOZ': 'MOZ', 'MRI': 'MRI', - 'MSR': 'MSR', 'MTN': 'MTN', 'MTQ': 'MTQ', 'MWI': 'MWI', 'MYA': 'MYA', 'MYT': 'MYT', 'NAM': 'NAM', - 'NCA': 'NCA', 'NCL': 'NCL', 'NEP': 'NEP', 'NFK': 'NFK', 'NIG': 'NIG', 'NIR': 'NIR', 'NIU': 'NIU', - 'NLD': 'NLD', 'NOR': 'NOR', 'NRU': 'NRU', 'NZL': 'NZL', 'OMA': 'OMA', 'PAK': 'PAK', 'PAN': 'PAN', - 'PAR': 'PAR', 'PCN': 'PCN', 'PER': 'PER', 'PHI': 'PHI', 'PLE': 'PLE', 'PLW': 'PLW', 'PNG': 'PNG', - 'POL': 'POL', 'POR': 'POR', 'PRK': 'PRK', 'PUR': 'PUR', 'QAT': 'QAT', 'REU': 'REU', 'ROU': 'ROU', - 'RSA': 'RSA', 'RUS': 'RUS', 'RWA': 'RWA', 'SAM': 'SAM', 'SCO': 'SCO', 'SDN': 'SDN', 'SEN': 'SEN', - 'SEY': 'SEY', 'SGS': 'SGS', 'SHN': 'SHN', 'SIN': 'SIN', 'SJM': 'SJM', 'SLE': 'SLE', 'SLV': 'SLV', - 'SMR': 'SMR', 'SOL': 'SOL', 'SOM': 'SOM', 'SPM': 'SPM', 'SRB': 'SRB', 'SSD': 'SSD', 'STP': 'STP', - 'SUI': 'SUI', 'SUR': 'SUR', 'SWZ': 'SWZ', 'SXM': 'SXM', 'SYR': 'SYR', 'TAH': 'TAH', 'TAN': 'TAN', - 'TCA': 'TCA', 'TGA': 'TGA', 'THA': 'THA', 'TJK': 'TJK', 'TKL': 'TKL', 'TKM': 'TKM', 'TLS': 'TLS', - 'TOG': 'TOG', 'TRI': 'TRI', 'TUN': 'TUN', 'TUR': 'TUR', 'TUV': 'TUV', 'TWN': 'TWN', 'UAE': 'UAE', - 'UGA': 'UGA', 'UKR': 'UKR', 'UMI': 'UMI', 'URU': 'URU', 'USA': 'USA', 'UZB': 'UZB', 'VAN': 'VAN', - 'VAT': 'VAT', 'VEN': 'VEN', 'VGB': 'VGB', 'VIE': 'VIE', 'VIN': 'VIN', 'VIR': 'VIR', 'WAL': 'WAL', - 'WLF': 'WLF', 'YEM': 'YEM', 'ZAM': 'ZAM', 'ZIM': 'ZIM', "EUR" : "EUR" - } - for key, value in regions.items(): - if f" {key} " in label: - region = value - - if region == None: - region = "" - return region - - def get_distributor(self, distributor_in): - distributor_list = [ - '01 DISTRIBUTION', '100 DESTINATIONS TRAVEL FILM', '101 FILMS', '1FILMS', '2 ENTERTAIN VIDEO', '20TH CENTURY FOX', '2L', '3D CONTENT HUB', '3D MEDIA', '3L FILM', '4DIGITAL', '4DVD', '4K ULTRA HD MOVIES', '4K UHD', '8-FILMS', '84 ENTERTAINMENT', '88 FILMS', '@ANIME', 'ANIME', 'A CONTRACORRIENTE', 'A CONTRACORRIENTE FILMS', 'A&E HOME VIDEO', 'A&E', 'A&M RECORDS', 'A+E NETWORKS', 'A+R', 'A-FILM', 'AAA', 'AB VIDÉO', 'AB VIDEO', 'ABC - (AUSTRALIAN BROADCASTING CORPORATION)', 'ABC', 'ABKCO', 'ABSOLUT MEDIEN', 'ABSOLUTE', 'ACCENT FILM ENTERTAINMENT', 'ACCENTUS', 'ACORN MEDIA', 'AD VITAM', 'ADA', 'ADITYA VIDEOS', 'ADSO FILMS', 'AFM RECORDS', 'AGFA', 'AIX RECORDS', - 'ALAMODE FILM', 'ALBA RECORDS', 'ALBANY RECORDS', 'ALBATROS', 'ALCHEMY', 'ALIVE', 'ALL ANIME', 'ALL INTERACTIVE ENTERTAINMENT', 'ALLEGRO', 'ALLIANCE', 'ALPHA MUSIC', 'ALTERDYSTRYBUCJA', 'ALTERED INNOCENCE', 'ALTITUDE FILM DISTRIBUTION', 'ALUCARD RECORDS', 'AMAZING D.C.', 'AMAZING DC', 'AMMO CONTENT', 'AMUSE SOFT ENTERTAINMENT', 'ANCONNECT', 'ANEC', 'ANIMATSU', 'ANIME HOUSE', 'ANIME LTD', 'ANIME WORKS', 'ANIMEIGO', 'ANIPLEX', 'ANOLIS ENTERTAINMENT', 'ANOTHER WORLD ENTERTAINMENT', 'AP INTERNATIONAL', 'APPLE', 'ARA MEDIA', 'ARBELOS', 'ARC ENTERTAINMENT', 'ARP SÉLECTION', 'ARP SELECTION', 'ARROW', 'ART SERVICE', 'ART VISION', 'ARTE ÉDITIONS', 'ARTE EDITIONS', 'ARTE VIDÉO', - 'ARTE VIDEO', 'ARTHAUS MUSIK', 'ARTIFICIAL EYE', 'ARTSPLOITATION FILMS', 'ARTUS FILMS', 'ASCOT ELITE HOME ENTERTAINMENT', 'ASIA VIDEO', 'ASMIK ACE', 'ASTRO RECORDS & FILMWORKS', 'ASYLUM', 'ATLANTIC FILM', 'ATLANTIC RECORDS', 'ATLAS FILM', 'AUDIO VISUAL ENTERTAINMENT', 'AURO-3D CREATIVE LABEL', 'AURUM', 'AV VISIONEN', 'AV-JET', 'AVALON', 'AVENTI', 'AVEX TRAX', 'AXIOM', 'AXIS RECORDS', 'AYNGARAN', 'BAC FILMS', 'BACH FILMS', 'BANDAI VISUAL', 'BARCLAY', 'BBC', 'BRITISH BROADCASTING CORPORATION', 'BBI FILMS', 'BBI', 'BCI HOME ENTERTAINMENT', 'BEGGARS BANQUET', 'BEL AIR CLASSIQUES', 'BELGA FILMS', 'BELVEDERE', 'BENELUX FILM DISTRIBUTORS', 'BENNETT-WATT MEDIA', 'BERLIN CLASSICS', 'BERLINER PHILHARMONIKER RECORDINGS', 'BEST ENTERTAINMENT', 'BEYOND HOME ENTERTAINMENT', 'BFI VIDEO', 'BFI', 'BRITISH FILM INSTITUTE', 'BFS ENTERTAINMENT', 'BFS', 'BHAVANI', 'BIBER RECORDS', 'BIG HOME VIDEO', 'BILDSTÖRUNG', - 'BILDSTORUNG', 'BILL ZEBUB', 'BIRNENBLATT', 'BIT WEL', 'BLACK BOX', 'BLACK HILL PICTURES', 'BLACK HILL', 'BLACK HOLE RECORDINGS', 'BLACK HOLE', 'BLAQOUT', 'BLAUFIELD MUSIC', 'BLAUFIELD', 'BLOCKBUSTER ENTERTAINMENT', 'BLOCKBUSTER', 'BLU PHASE MEDIA', 'BLU-RAY ONLY', 'BLU-RAY', 'BLURAY ONLY', 'BLURAY', 'BLUE GENTIAN RECORDS', 'BLUE KINO', 'BLUE UNDERGROUND', 'BMG/ARISTA', 'BMG', 'BMGARISTA', 'BMG ARISTA', 'ARISTA', 'ARISTA/BMG', 'ARISTABMG', 'ARISTA BMG', 'BONTON FILM', 'BONTON', 'BOOMERANG PICTURES', 'BOOMERANG', 'BQHL ÉDITIONS', 'BQHL EDITIONS', 'BQHL', 'BREAKING GLASS', 'BRIDGESTONE', 'BRINK', 'BROAD GREEN PICTURES', 'BROAD GREEN', 'BUSCH MEDIA GROUP', 'BUSCH', 'C MAJOR', 'C.B.S.', 'CAICHANG', 'CALIFÓRNIA FILMES', 'CALIFORNIA FILMES', 'CALIFORNIA', 'CAMEO', 'CAMERA OBSCURA', 'CAMERATA', 'CAMP MOTION PICTURES', 'CAMP MOTION', 'CAPELIGHT PICTURES', 'CAPELIGHT', 'CAPITOL', 'CAPITOL RECORDS', 'CAPRICCI', 'CARGO RECORDS', 'CARLOTTA FILMS', 'CARLOTTA', 'CARLOTA', 'CARMEN FILM', 'CASCADE', 'CATCHPLAY', 'CAULDRON FILMS', 'CAULDRON', 'CBS TELEVISION STUDIOS', 'CBS', 'CCTV', 'CCV ENTERTAINMENT', 'CCV', 'CD BABY', 'CD LAND', 'CECCHI GORI', 'CENTURY MEDIA', 'CHUAN XUN SHI DAI MULTIMEDIA', 'CINE-ASIA', 'CINÉART', 'CINEART', 'CINEDIGM', 'CINEFIL IMAGICA', 'CINEMA EPOCH', 'CINEMA GUILD', 'CINEMA LIBRE STUDIOS', 'CINEMA MONDO', 'CINEMATIC VISION', 'CINEPLOIT RECORDS', 'CINESTRANGE EXTREME', 'CITEL VIDEO', 'CITEL', 'CJ ENTERTAINMENT', 'CJ', 'CLASSIC MEDIA', 'CLASSICFLIX', 'CLASSICLINE', 'CLAUDIO RECORDS', 'CLEAR VISION', 'CLEOPATRA', 'CLOSE UP', 'CMS MEDIA LIMITED', 'CMV LASERVISION', 'CN ENTERTAINMENT', 'CODE RED', 'COHEN MEDIA GROUP', 'COHEN', 'COIN DE MIRE CINÉMA', 'COIN DE MIRE CINEMA', 'COLOSSEO FILM', 'COLUMBIA', 'COLUMBIA PICTURES', 'COLUMBIA/TRI-STAR', 'TRI-STAR', 'COMMERCIAL MARKETING', 'CONCORD MUSIC GROUP', 'CONCORDE VIDEO', 'CONDOR', 'CONSTANTIN FILM', 'CONSTANTIN', 'CONSTANTINO FILMES', 'CONSTANTINO', 'CONSTRUCTIVE MEDIA SERVICE', 'CONSTRUCTIVE', 'CONTENT ZONE', 'CONTENTS GATE', 'COQUEIRO VERDE', 'CORNERSTONE MEDIA', 'CORNERSTONE', 'CP DIGITAL', 'CREST MOVIES', 'CRITERION', 'CRITERION COLLECTION', 'CC', 'CRYSTAL CLASSICS', 'CULT EPICS', 'CULT FILMS', 'CULT VIDEO', 'CURZON FILM WORLD', 'D FILMS', "D'AILLY COMPANY", 'DAILLY COMPANY', 'D AILLY COMPANY', "D'AILLY", 'DAILLY', 'D AILLY', 'DA CAPO', 'DA MUSIC', "DALL'ANGELO PICTURES", 'DALLANGELO PICTURES', "DALL'ANGELO", 'DALL ANGELO PICTURES', 'DALL ANGELO', 'DAREDO', 'DARK FORCE ENTERTAINMENT', 'DARK FORCE', 'DARK SIDE RELEASING', 'DARK SIDE', 'DAZZLER MEDIA', 'DAZZLER', 'DCM PICTURES', 'DCM', 'DEAPLANETA', 'DECCA', 'DEEPJOY', 'DEFIANT SCREEN ENTERTAINMENT', 'DEFIANT SCREEN', 'DEFIANT', 'DELOS', 'DELPHIAN RECORDS', 'DELPHIAN', 'DELTA MUSIC & ENTERTAINMENT', 'DELTA MUSIC AND ENTERTAINMENT', 'DELTA MUSIC ENTERTAINMENT', 'DELTA MUSIC', 'DELTAMAC CO. LTD.', 'DELTAMAC CO LTD', 'DELTAMAC CO', 'DELTAMAC', 'DEMAND MEDIA', 'DEMAND', 'DEP', 'DEUTSCHE GRAMMOPHON', 'DFW', 'DGM', 'DIAPHANA', 'DIGIDREAMS STUDIOS', 'DIGIDREAMS', 'DIGITAL ENVIRONMENTS', 'DIGITAL', 'DISCOTEK MEDIA', 'DISCOVERY CHANNEL', 'DISCOVERY', 'DISK KINO', 'DISNEY / BUENA VISTA', 'DISNEY', 'BUENA VISTA', 'DISNEY BUENA VISTA', 'DISTRIBUTION SELECT', 'DIVISA', 'DNC ENTERTAINMENT', 'DNC', 'DOGWOOF', 'DOLMEN HOME VIDEO', 'DOLMEN', 'DONAU FILM', 'DONAU', 'DORADO FILMS', 'DORADO', 'DRAFTHOUSE FILMS', 'DRAFTHOUSE', 'DRAGON FILM ENTERTAINMENT', 'DRAGON ENTERTAINMENT', 'DRAGON FILM', 'DRAGON', 'DREAMWORKS', 'DRIVE ON RECORDS', 'DRIVE ON', 'DRIVE-ON', 'DRIVEON', 'DS MEDIA', 'DTP ENTERTAINMENT AG', 'DTP ENTERTAINMENT', 'DTP AG', 'DTP', 'DTS ENTERTAINMENT', 'DTS', 'DUKE MARKETING', 'DUKE VIDEO DISTRIBUTION', 'DUKE', 'DUTCH FILMWORKS', 'DUTCH', 'DVD INTERNATIONAL', 'DVD', 'DYBEX', 'DYNAMIC', 'DYNIT', 'E1 ENTERTAINMENT', 'E1', 'EAGLE ENTERTAINMENT', 'EAGLE HOME ENTERTAINMENT PVT.LTD.', 'EAGLE HOME ENTERTAINMENT PVTLTD', 'EAGLE HOME ENTERTAINMENT PVT LTD', 'EAGLE HOME ENTERTAINMENT', 'EAGLE PICTURES', 'EAGLE ROCK ENTERTAINMENT', 'EAGLE ROCK', 'EAGLE VISION MEDIA', 'EAGLE VISION', 'EARMUSIC', 'EARTH ENTERTAINMENT', 'EARTH', 'ECHO BRIDGE ENTERTAINMENT', 'ECHO BRIDGE', 'EDEL GERMANY GMBH', 'EDEL GERMANY', 'EDEL RECORDS', 'EDITION TONFILM', 'EDITIONS MONTPARNASSE', 'EDKO FILMS LTD.', 'EDKO FILMS LTD', 'EDKO FILMS', - 'EDKO', "EIN'S M&M CO", 'EINS M&M CO', "EIN'S M&M", 'EINS M&M', 'ELEA-MEDIA', 'ELEA MEDIA', 'ELEA', 'ELECTRIC PICTURE', 'ELECTRIC', 'ELEPHANT FILMS', 'ELEPHANT', 'ELEVATION', 'EMI', 'EMON', 'EMS', 'EMYLIA', 'ENE MEDIA', 'ENE', 'ENTERTAINMENT IN VIDEO', 'ENTERTAINMENT IN', 'ENTERTAINMENT ONE', 'ENTERTAINMENT ONE FILMS CANADA INC.', 'ENTERTAINMENT ONE FILMS CANADA INC', 'ENTERTAINMENT ONE FILMS CANADA', 'ENTERTAINMENT ONE CANADA INC', 'ENTERTAINMENT ONE CANADA', 'ENTERTAINMENTONE', 'EONE', 'EOS', 'EPIC PICTURES', 'EPIC', 'EPIC RECORDS', 'ERATO', 'EROS', 'ESC EDITIONS', 'ESCAPI MEDIA BV', 'ESOTERIC RECORDINGS', 'ESPN FILMS', 'EUREKA ENTERTAINMENT', 'EUREKA', 'EURO PICTURES', 'EURO VIDEO', 'EUROARTS', 'EUROPA FILMES', 'EUROPA', 'EUROPACORP', 'EUROZOOM', 'EXCEL', 'EXPLOSIVE MEDIA', 'EXPLOSIVE', 'EXTRALUCID FILMS', 'EXTRALUCID', 'EYE SEE MOVIES', 'EYE SEE', 'EYK MEDIA', 'EYK', 'FABULOUS FILMS', 'FABULOUS', 'FACTORIS FILMS', 'FACTORIS', 'FARAO RECORDS', 'FARBFILM HOME ENTERTAINMENT', 'FARBFILM ENTERTAINMENT', 'FARBFILM HOME', 'FARBFILM', 'FEELGOOD ENTERTAINMENT', 'FEELGOOD', 'FERNSEHJUWELEN', 'FILM CHEST', 'FILM MEDIA', 'FILM MOVEMENT', 'FILM4', 'FILMART', 'FILMAURO', 'FILMAX', 'FILMCONFECT HOME ENTERTAINMENT', 'FILMCONFECT ENTERTAINMENT', 'FILMCONFECT HOME', 'FILMCONFECT', 'FILMEDIA', 'FILMJUWELEN', 'FILMOTEKA NARODAWA', 'FILMRISE', 'FINAL CUT ENTERTAINMENT', 'FINAL CUT', 'FIREHOUSE 12 RECORDS', 'FIREHOUSE 12', 'FIRST INTERNATIONAL PRODUCTION', 'FIRST INTERNATIONAL', 'FIRST LOOK STUDIOS', 'FIRST LOOK', 'FLAGMAN TRADE', 'FLASHSTAR FILMES', 'FLASHSTAR', 'FLICKER ALLEY', 'FNC ADD CULTURE', 'FOCUS FILMES', 'FOCUS', 'FOKUS MEDIA', 'FOKUSA', 'FOX PATHE EUROPA', 'FOX PATHE', 'FOX EUROPA', 'FOX/MGM', 'FOX MGM', 'MGM', 'MGM/FOX', 'FOX', 'FPE', 'FRANCE TÉLÉVISIONS DISTRIBUTION', 'FRANCE TELEVISIONS DISTRIBUTION', 'FRANCE TELEVISIONS', 'FRANCE', 'FREE DOLPHIN ENTERTAINMENT', 'FREE DOLPHIN', 'FREESTYLE DIGITAL MEDIA', 'FREESTYLE DIGITAL', 'FREESTYLE', 'FREMANTLE HOME ENTERTAINMENT', 'FREMANTLE ENTERTAINMENT', 'FREMANTLE HOME', 'FREMANTL', 'FRENETIC FILMS', 'FRENETIC', 'FRONTIER WORKS', 'FRONTIER', 'FRONTIERS MUSIC', 'FRONTIERS RECORDS', 'FS FILM OY', 'FS FILM', 'FULL MOON FEATURES', 'FULL MOON', 'FUN CITY EDITIONS', 'FUN CITY', - 'FUNIMATION ENTERTAINMENT', 'FUNIMATION', 'FUSION', 'FUTUREFILM', 'G2 PICTURES', 'G2', 'GAGA COMMUNICATIONS', 'GAGA', 'GAIAM', 'GALAPAGOS', 'GAMMA HOME ENTERTAINMENT', 'GAMMA ENTERTAINMENT', 'GAMMA HOME', 'GAMMA', 'GARAGEHOUSE PICTURES', 'GARAGEHOUSE', 'GARAGEPLAY (車庫娛樂)', '車庫娛樂', 'GARAGEPLAY (Che Ku Yu Le )', 'GARAGEPLAY', 'Che Ku Yu Le', 'GAUMONT', 'GEFFEN', 'GENEON ENTERTAINMENT', 'GENEON', 'GENEON UNIVERSAL ENTERTAINMENT', 'GENERAL VIDEO RECORDING', 'GLASS DOLL FILMS', 'GLASS DOLL', 'GLOBE MUSIC MEDIA', 'GLOBE MUSIC', 'GLOBE MEDIA', 'GLOBE', 'GO ENTERTAIN', 'GO', 'GOLDEN HARVEST', 'GOOD!MOVIES', 'GOOD! MOVIES', 'GOOD MOVIES', 'GRAPEVINE VIDEO', 'GRAPEVINE', 'GRASSHOPPER FILM', 'GRASSHOPPER FILMS', 'GRASSHOPPER', 'GRAVITAS VENTURES', 'GRAVITAS', 'GREAT MOVIES', 'GREAT', 'GREEN APPLE ENTERTAINMENT', 'GREEN ENTERTAINMENT', 'GREEN APPLE', 'GREEN', 'GREENNARAE MEDIA', 'GREENNARAE', 'GRINDHOUSE RELEASING', 'GRINDHOUSE', 'GRIND HOUSE', 'GRYPHON ENTERTAINMENT', 'GRYPHON', 'GUNPOWDER & SKY', 'GUNPOWDER AND SKY', 'GUNPOWDER SKY', 'GUNPOWDER + SKY', 'GUNPOWDER', 'HANABEE ENTERTAINMENT', 'HANABEE', 'HANNOVER HOUSE', 'HANNOVER', 'HANSESOUND', 'HANSE SOUND', 'HANSE', 'HAPPINET', 'HARMONIA MUNDI', 'HARMONIA', 'HBO', 'HDC', 'HEC', 'HELL & BACK RECORDINGS', 'HELL AND BACK RECORDINGS', 'HELL & BACK', 'HELL AND BACK', "HEN'S TOOTH VIDEO", 'HENS TOOTH VIDEO', "HEN'S TOOTH", 'HENS TOOTH', 'HIGH FLIERS', 'HIGHLIGHT', 'HILLSONG', 'HISTORY CHANNEL', 'HISTORY', 'HK VIDÉO', 'HK VIDEO', 'HK', 'HMH HAMBURGER MEDIEN HAUS', 'HAMBURGER MEDIEN HAUS', 'HMH HAMBURGER MEDIEN', 'HMH HAMBURGER', 'HMH', 'HOLLYWOOD CLASSIC ENTERTAINMENT', 'HOLLYWOOD CLASSIC', 'HOLLYWOOD PICTURES', 'HOLLYWOOD', 'HOPSCOTCH ENTERTAINMENT', 'HOPSCOTCH', 'HPM', 'HÄNNSLER CLASSIC', 'HANNSLER CLASSIC', 'HANNSLER', 'I-CATCHER', 'I CATCHER', 'ICATCHER', 'I-ON NEW MEDIA', 'I ON NEW MEDIA', 'ION NEW MEDIA', 'ION MEDIA', 'I-ON', 'ION', 'IAN PRODUCTIONS', 'IAN', 'ICESTORM', 'ICON FILM DISTRIBUTION', 'ICON DISTRIBUTION', 'ICON FILM', 'ICON', 'IDEALE AUDIENCE', 'IDEALE', 'IFC FILMS', 'IFC', 'IFILM', 'ILLUSIONS UNLTD.', 'ILLUSIONS UNLTD', 'ILLUSIONS', 'IMAGE ENTERTAINMENT', 'IMAGE', 'IMAGEM FILMES', 'IMAGEM', 'IMOVISION', 'IMPERIAL CINEPIX', 'IMPRINT', 'IMPULS HOME ENTERTAINMENT', 'IMPULS ENTERTAINMENT', 'IMPULS HOME', 'IMPULS', 'IN-AKUSTIK', 'IN AKUSTIK', 'INAKUSTIK', 'INCEPTION MEDIA GROUP', 'INCEPTION MEDIA', 'INCEPTION GROUP', 'INCEPTION', 'INDEPENDENT', 'INDICAN', 'INDIE RIGHTS', 'INDIE', 'INDIGO', 'INFO', 'INJOINGAN', 'INKED PICTURES', 'INKED', 'INSIDE OUT MUSIC', 'INSIDE MUSIC', 'INSIDE OUT', 'INSIDE', 'INTERCOM', 'INTERCONTINENTAL VIDEO', 'INTERCONTINENTAL', 'INTERGROOVE', 'INTERSCOPE', 'INVINCIBLE PICTURES', 'INVINCIBLE', 'ISLAND/MERCURY', 'ISLAND MERCURY', 'ISLANDMERCURY', 'ISLAND & MERCURY', 'ISLAND AND MERCURY', 'ISLAND', 'ITN', 'ITV DVD', 'ITV', 'IVC', 'IVE ENTERTAINMENT', 'IVE', 'J&R ADVENTURES', 'J&R', 'JR', 'JAKOB', 'JONU MEDIA', 'JONU', 'JRB PRODUCTIONS', 'JRB', 'JUST BRIDGE ENTERTAINMENT', 'JUST BRIDGE', 'JUST ENTERTAINMENT', 'JUST', 'KABOOM ENTERTAINMENT', 'KABOOM', 'KADOKAWA ENTERTAINMENT', 'KADOKAWA', 'KAIROS', 'KALEIDOSCOPE ENTERTAINMENT', 'KALEIDOSCOPE', 'KAM & RONSON ENTERPRISES', 'KAM & RONSON', 'KAM&RONSON ENTERPRISES', 'KAM&RONSON', 'KAM AND RONSON ENTERPRISES', 'KAM AND RONSON', 'KANA HOME VIDEO', 'KARMA FILMS', 'KARMA', 'KATZENBERGER', 'KAZE', - 'KBS MEDIA', 'KBS', 'KD MEDIA', 'KD', 'KING MEDIA', 'KING', 'KING RECORDS', 'KINO LORBER', 'KINO', 'KINO SWIAT', 'KINOKUNIYA', 'KINOWELT HOME ENTERTAINMENT/DVD', 'KINOWELT HOME ENTERTAINMENT', 'KINOWELT ENTERTAINMENT', 'KINOWELT HOME DVD', 'KINOWELT ENTERTAINMENT/DVD', 'KINOWELT DVD', 'KINOWELT', 'KIT PARKER FILMS', 'KIT PARKER', 'KITTY MEDIA', 'KNM HOME ENTERTAINMENT', 'KNM ENTERTAINMENT', 'KNM HOME', 'KNM', 'KOBA FILMS', 'KOBA', 'KOCH ENTERTAINMENT', 'KOCH MEDIA', 'KOCH', 'KRAKEN RELEASING', 'KRAKEN', 'KSCOPE', 'KSM', 'KULTUR', "L'ATELIER D'IMAGES", "LATELIER D'IMAGES", "L'ATELIER DIMAGES", 'LATELIER DIMAGES', "L ATELIER D'IMAGES", "L'ATELIER D IMAGES", - 'L ATELIER D IMAGES', "L'ATELIER", 'L ATELIER', 'LATELIER', 'LA AVENTURA AUDIOVISUAL', 'LA AVENTURA', 'LACE GROUP', 'LACE', 'LASER PARADISE', 'LAYONS', 'LCJ EDITIONS', 'LCJ', 'LE CHAT QUI FUME', 'LE PACTE', 'LEDICK FILMHANDEL', 'LEGEND', 'LEOMARK STUDIOS', 'LEOMARK', 'LEONINE FILMS', 'LEONINE', 'LICHTUNG MEDIA LTD', 'LICHTUNG LTD', 'LICHTUNG MEDIA LTD.', 'LICHTUNG LTD.', 'LICHTUNG MEDIA', 'LICHTUNG', 'LIGHTHOUSE HOME ENTERTAINMENT', 'LIGHTHOUSE ENTERTAINMENT', 'LIGHTHOUSE HOME', 'LIGHTHOUSE', 'LIGHTYEAR', 'LIONSGATE FILMS', 'LIONSGATE', 'LIZARD CINEMA TRADE', 'LLAMENTOL', 'LOBSTER FILMS', 'LOBSTER', 'LOGON', 'LORBER FILMS', 'LORBER', 'LOS BANDITOS FILMS', 'LOS BANDITOS', 'LOUD & PROUD RECORDS', 'LOUD AND PROUD RECORDS', 'LOUD & PROUD', 'LOUD AND PROUD', 'LSO LIVE', 'LUCASFILM', 'LUCKY RED', 'LUMIÈRE HOME ENTERTAINMENT', 'LUMIERE HOME ENTERTAINMENT', 'LUMIERE ENTERTAINMENT', 'LUMIERE HOME', 'LUMIERE', 'M6 VIDEO', 'M6', 'MAD DIMENSION', 'MADMAN ENTERTAINMENT', 'MADMAN', 'MAGIC BOX', 'MAGIC PLAY', 'MAGNA HOME ENTERTAINMENT', 'MAGNA ENTERTAINMENT', 'MAGNA HOME', 'MAGNA', 'MAGNOLIA PICTURES', 'MAGNOLIA', 'MAIDEN JAPAN', 'MAIDEN', 'MAJENG MEDIA', 'MAJENG', 'MAJESTIC HOME ENTERTAINMENT', 'MAJESTIC ENTERTAINMENT', 'MAJESTIC HOME', 'MAJESTIC', 'MANGA HOME ENTERTAINMENT', 'MANGA ENTERTAINMENT', 'MANGA HOME', 'MANGA', 'MANTA LAB', 'MAPLE STUDIOS', 'MAPLE', 'MARCO POLO PRODUCTION', 'MARCO POLO', 'MARIINSKY', 'MARVEL STUDIOS', 'MARVEL', 'MASCOT RECORDS', 'MASCOT', 'MASSACRE VIDEO', 'MASSACRE', 'MATCHBOX', 'MATRIX D', 'MAXAM', 'MAYA HOME ENTERTAINMENT', 'MAYA ENTERTAINMENT', 'MAYA HOME', 'MAYAT', 'MDG', 'MEDIA BLASTERS', 'MEDIA FACTORY', 'MEDIA TARGET DISTRIBUTION', 'MEDIA TARGET', 'MEDIAINVISION', 'MEDIATOON', 'MEDIATRES ESTUDIO', 'MEDIATRES STUDIO', 'MEDIATRES', 'MEDICI ARTS', 'MEDICI CLASSICS', 'MEDIUMRARE ENTERTAINMENT', 'MEDIUMRARE', 'MEDUSA', 'MEGASTAR', 'MEI AH', 'MELI MÉDIAS', 'MELI MEDIAS', 'MEMENTO FILMS', 'MEMENTO', 'MENEMSHA FILMS', 'MENEMSHA', 'MERCURY', 'MERCURY STUDIOS', 'MERGE SOFT PRODUCTIONS', 'MERGE PRODUCTIONS', 'MERGE SOFT', 'MERGE', 'METAL BLADE RECORDS', 'METAL BLADE', 'METEOR', 'METRO-GOLDWYN-MAYER', 'METRO GOLDWYN MAYER', 'METROGOLDWYNMAYER', 'METRODOME VIDEO', 'METRODOME', 'METROPOLITAN', 'MFA+', 'MFA', 'MIG FILMGROUP', 'MIG', 'MILESTONE', 'MILL CREEK ENTERTAINMENT', 'MILL CREEK', 'MILLENNIUM MEDIA', 'MILLENNIUM', 'MIRAGE ENTERTAINMENT', 'MIRAGE', 'MIRAMAX', 'MISTERIYA ZVUKA', 'MK2', 'MODE RECORDS', 'MODE', 'MOMENTUM PICTURES', 'MONDO HOME ENTERTAINMENT', 'MONDO ENTERTAINMENT', 'MONDO HOME', 'MONDO MACABRO', 'MONGREL MEDIA', 'MONOLIT', 'MONOLITH VIDEO', 'MONOLITH', 'MONSTER PICTURES', 'MONSTER', 'MONTEREY VIDEO', 'MONTEREY', 'MONUMENT RELEASING', 'MONUMENT', 'MORNINGSTAR', 'MORNING STAR', 'MOSERBAER', 'MOVIEMAX', 'MOVINSIDE', 'MPI MEDIA GROUP', 'MPI MEDIA', 'MPI', 'MR. BONGO FILMS', 'MR BONGO FILMS', 'MR BONGO', 'MRG (MERIDIAN)', 'MRG MERIDIAN', 'MRG', 'MERIDIAN', 'MUBI', 'MUG SHOT PRODUCTIONS', 'MUG SHOT', 'MULTIMUSIC', 'MULTI-MUSIC', 'MULTI MUSIC', 'MUSE', 'MUSIC BOX FILMS', 'MUSIC BOX', 'MUSICBOX', 'MUSIC BROKERS', 'MUSIC THEORIES', 'MUSIC VIDEO DISTRIBUTORS', 'MUSIC VIDEO', 'MUSTANG ENTERTAINMENT', 'MUSTANG', 'MVD VISUAL', 'MVD', 'MVD/VSC', 'MVL', 'MVM ENTERTAINMENT', 'MVM', 'MYNDFORM', 'MYSTIC NIGHT PICTURES', 'MYSTIC NIGHT', 'NAMELESS MEDIA', 'NAMELESS', 'NAPALM RECORDS', 'NAPALM', 'NATIONAL ENTERTAINMENT MEDIA', 'NATIONAL ENTERTAINMENT', 'NATIONAL MEDIA', 'NATIONAL FILM ARCHIVE', 'NATIONAL ARCHIVE', 'NATIONAL FILM', 'NATIONAL GEOGRAPHIC', 'NAT GEO TV', 'NAT GEO', 'NGO', 'NAXOS', 'NBCUNIVERSAL ENTERTAINMENT JAPAN', 'NBC UNIVERSAL ENTERTAINMENT JAPAN', 'NBCUNIVERSAL JAPAN', 'NBC UNIVERSAL JAPAN', 'NBC JAPAN', 'NBO ENTERTAINMENT', 'NBO', 'NEOS', 'NETFLIX', 'NETWORK', 'NEW BLOOD', 'NEW DISC', 'NEW KSM', 'NEW LINE CINEMA', 'NEW LINE', 'NEW MOVIE TRADING CO. LTD', 'NEW MOVIE TRADING CO LTD', 'NEW MOVIE TRADING CO', 'NEW MOVIE TRADING', 'NEW WAVE FILMS', 'NEW WAVE', 'NFI', 'NHK', 'NIPPONART', 'NIS AMERICA', 'NJUTAFILMS', 'NOBLE ENTERTAINMENT', 'NOBLE', 'NORDISK FILM', 'NORDISK', 'NORSK FILM', 'NORSK', 'NORTH AMERICAN MOTION PICTURES', 'NOS AUDIOVISUAIS', 'NOTORIOUS PICTURES', 'NOTORIOUS', 'NOVA MEDIA', 'NOVA', 'NOVA SALES AND DISTRIBUTION', 'NOVA SALES & DISTRIBUTION', 'NSM', 'NSM RECORDS', 'NUCLEAR BLAST', 'NUCLEUS FILMS', 'NUCLEUS', 'OBERLIN MUSIC', 'OBERLIN', 'OBRAS-PRIMAS DO CINEMA', 'OBRAS PRIMAS DO CINEMA', 'OBRASPRIMAS DO CINEMA', 'OBRAS-PRIMAS CINEMA', 'OBRAS PRIMAS CINEMA', 'OBRASPRIMAS CINEMA', 'OBRAS-PRIMAS', 'OBRAS PRIMAS', 'OBRASPRIMAS', 'ODEON', 'OFDB FILMWORKS', 'OFDB', 'OLIVE FILMS', 'OLIVE', 'ONDINE', 'ONSCREEN FILMS', 'ONSCREEN', 'OPENING DISTRIBUTION', 'OPERA AUSTRALIA', 'OPTIMUM HOME ENTERTAINMENT', 'OPTIMUM ENTERTAINMENT', 'OPTIMUM HOME', 'OPTIMUM', 'OPUS ARTE', 'ORANGE STUDIO', 'ORANGE', 'ORLANDO EASTWOOD FILMS', 'ORLANDO FILMS', 'ORLANDO EASTWOOD', 'ORLANDO', 'ORUSTAK PICTURES', 'ORUSTAK', 'OSCILLOSCOPE PICTURES', 'OSCILLOSCOPE', 'OUTPLAY', 'PALISADES TARTAN', 'PAN VISION', 'PANVISION', 'PANAMINT CINEMA', 'PANAMINT', 'PANDASTORM ENTERTAINMENT', 'PANDA STORM ENTERTAINMENT', 'PANDASTORM', 'PANDA STORM', 'PANDORA FILM', 'PANDORA', 'PANEGYRIC', 'PANORAMA', 'PARADE DECK FILMS', 'PARADE DECK', 'PARADISE', 'PARADISO FILMS', 'PARADOX', 'PARAMOUNT PICTURES', 'PARAMOUNT', 'PARIS FILMES', 'PARIS FILMS', 'PARIS', 'PARK CIRCUS', 'PARLOPHONE', 'PASSION RIVER', 'PATHE DISTRIBUTION', 'PATHE', 'PBS', 'PEACE ARCH TRINITY', 'PECCADILLO PICTURES', 'PEPPERMINT', 'PHASE 4 FILMS', 'PHASE 4', 'PHILHARMONIA BAROQUE', 'PICTURE HOUSE ENTERTAINMENT', 'PICTURE ENTERTAINMENT', 'PICTURE HOUSE', 'PICTURE', 'PIDAX', - 'PINK FLOYD RECORDS', 'PINK FLOYD', 'PINNACLE FILMS', 'PINNACLE', 'PLAIN', 'PLATFORM ENTERTAINMENT LIMITED', 'PLATFORM ENTERTAINMENT LTD', 'PLATFORM ENTERTAINMENT LTD.', 'PLATFORM ENTERTAINMENT', 'PLATFORM', 'PLAYARTE', 'PLG UK CLASSICS', 'PLG UK', 'PLG', 'POLYBAND & TOPPIC VIDEO/WVG', 'POLYBAND AND TOPPIC VIDEO/WVG', 'POLYBAND & TOPPIC VIDEO WVG', 'POLYBAND & TOPPIC VIDEO AND WVG', 'POLYBAND & TOPPIC VIDEO & WVG', 'POLYBAND AND TOPPIC VIDEO WVG', 'POLYBAND AND TOPPIC VIDEO AND WVG', 'POLYBAND AND TOPPIC VIDEO & WVG', 'POLYBAND & TOPPIC VIDEO', 'POLYBAND AND TOPPIC VIDEO', 'POLYBAND & TOPPIC', 'POLYBAND AND TOPPIC', 'POLYBAND', 'WVG', 'POLYDOR', 'PONY', 'PONY CANYON', 'POTEMKINE', 'POWERHOUSE FILMS', 'POWERHOUSE', 'POWERSTATIOM', 'PRIDE & JOY', 'PRIDE AND JOY', 'PRINZ MEDIA', 'PRINZ', 'PRIS AUDIOVISUAIS', 'PRO VIDEO', 'PRO-VIDEO', 'PRO-MOTION', 'PRO MOTION', 'PROD. JRB', 'PROD JRB', 'PRODISC', 'PROKINO', 'PROVOGUE RECORDS', 'PROVOGUE', 'PROWARE', 'PULP VIDEO', 'PULP', 'PULSE VIDEO', 'PULSE', 'PURE AUDIO RECORDINGS', 'PURE AUDIO', 'PURE FLIX ENTERTAINMENT', 'PURE FLIX', 'PURE ENTERTAINMENT', 'PYRAMIDE VIDEO', 'PYRAMIDE', 'QUALITY FILMS', 'QUALITY', 'QUARTO VALLEY RECORDS', 'QUARTO VALLEY', 'QUESTAR', 'R SQUARED FILMS', 'R SQUARED', 'RAPID EYE MOVIES', 'RAPID EYE', 'RARO VIDEO', 'RARO', 'RAROVIDEO U.S.', 'RAROVIDEO US', 'RARO VIDEO US', 'RARO VIDEO U.S.', 'RARO U.S.', 'RARO US', 'RAVEN BANNER RELEASING', 'RAVEN BANNER', 'RAVEN', 'RAZOR DIGITAL ENTERTAINMENT', 'RAZOR DIGITAL', 'RCA', 'RCO LIVE', 'RCO', 'RCV', 'REAL GONE MUSIC', 'REAL GONE', 'REANIMEDIA', 'REANI MEDIA', 'REDEMPTION', 'REEL', 'RELIANCE HOME VIDEO & GAMES', 'RELIANCE HOME VIDEO AND GAMES', 'RELIANCE HOME VIDEO', 'RELIANCE VIDEO', 'RELIANCE HOME', 'RELIANCE', 'REM CULTURE', 'REMAIN IN LIGHT', 'REPRISE', 'RESEN', 'RETROMEDIA', 'REVELATION FILMS LTD.', 'REVELATION FILMS LTD', 'REVELATION FILMS', 'REVELATION LTD.', 'REVELATION LTD', 'REVELATION', 'REVOLVER ENTERTAINMENT', 'REVOLVER', 'RHINO MUSIC', 'RHINO', 'RHV', 'RIGHT STUF', 'RIMINI EDITIONS', 'RISING SUN MEDIA', 'RLJ ENTERTAINMENT', 'RLJ', 'ROADRUNNER RECORDS', 'ROADSHOW ENTERTAINMENT', 'ROADSHOW', 'RONE', 'RONIN FLIX', 'ROTANA HOME ENTERTAINMENT', 'ROTANA ENTERTAINMENT', 'ROTANA HOME', 'ROTANA', 'ROUGH TRADE', - 'ROUNDER', 'SAFFRON HILL FILMS', 'SAFFRON HILL', 'SAFFRON', 'SAMUEL GOLDWYN FILMS', 'SAMUEL GOLDWYN', 'SAN FRANCISCO SYMPHONY', 'SANDREW METRONOME', 'SAPHRANE', 'SAVOR', 'SCANBOX ENTERTAINMENT', 'SCANBOX', 'SCENIC LABS', 'SCHRÖDERMEDIA', 'SCHRODERMEDIA', 'SCHRODER MEDIA', 'SCORPION RELEASING', 'SCORPION', 'SCREAM TEAM RELEASING', 'SCREAM TEAM', 'SCREEN MEDIA', 'SCREEN', 'SCREENBOUND PICTURES', 'SCREENBOUND', 'SCREENWAVE MEDIA', 'SCREENWAVE', 'SECOND RUN', 'SECOND SIGHT', 'SEEDSMAN GROUP', 'SELECT VIDEO', 'SELECTA VISION', 'SENATOR', 'SENTAI FILMWORKS', 'SENTAI', 'SEVEN7', 'SEVERIN FILMS', 'SEVERIN', 'SEVILLE', 'SEYONS ENTERTAINMENT', 'SEYONS', 'SF STUDIOS', 'SGL ENTERTAINMENT', 'SGL', 'SHAMELESS', 'SHAMROCK MEDIA', 'SHAMROCK', 'SHANGHAI EPIC MUSIC ENTERTAINMENT', 'SHANGHAI EPIC ENTERTAINMENT', 'SHANGHAI EPIC MUSIC', 'SHANGHAI MUSIC ENTERTAINMENT', 'SHANGHAI ENTERTAINMENT', 'SHANGHAI MUSIC', 'SHANGHAI', 'SHEMAROO', 'SHOCHIKU', 'SHOCK', 'SHOGAKU KAN', 'SHOUT FACTORY', 'SHOUT! FACTORY', 'SHOUT', 'SHOUT!', 'SHOWBOX', 'SHOWTIME ENTERTAINMENT', 'SHOWTIME', 'SHRIEK SHOW', 'SHUDDER', 'SIDONIS', 'SIDONIS CALYSTA', 'SIGNAL ONE ENTERTAINMENT', 'SIGNAL ONE', 'SIGNATURE ENTERTAINMENT', 'SIGNATURE', 'SILVER VISION', 'SINISTER FILM', 'SINISTER', 'SIREN VISUAL ENTERTAINMENT', 'SIREN VISUAL', 'SIREN ENTERTAINMENT', 'SIREN', 'SKANI', 'SKY DIGI', - 'SLASHER // VIDEO', 'SLASHER / VIDEO', 'SLASHER VIDEO', 'SLASHER', 'SLOVAK FILM INSTITUTE', 'SLOVAK FILM', 'SFI', 'SM LIFE DESIGN GROUP', 'SMOOTH PICTURES', 'SMOOTH', 'SNAPPER MUSIC', 'SNAPPER', 'SODA PICTURES', 'SODA', 'SONO LUMINUS', 'SONY MUSIC', 'SONY PICTURES', 'SONY', 'SONY PICTURES CLASSICS', 'SONY CLASSICS', 'SOUL MEDIA', 'SOUL', 'SOULFOOD MUSIC DISTRIBUTION', 'SOULFOOD DISTRIBUTION', 'SOULFOOD MUSIC', 'SOULFOOD', 'SOYUZ', 'SPECTRUM', 'SPENTZOS FILM', 'SPENTZOS', 'SPIRIT ENTERTAINMENT', 'SPIRIT', 'SPIRIT MEDIA GMBH', 'SPIRIT MEDIA', 'SPLENDID ENTERTAINMENT', 'SPLENDID FILM', 'SPO', 'SQUARE ENIX', 'SRI BALAJI VIDEO', 'SRI BALAJI', 'SRI', 'SRI VIDEO', 'SRS CINEMA', 'SRS', 'SSO RECORDINGS', 'SSO', 'ST2 MUSIC', 'ST2', 'STAR MEDIA ENTERTAINMENT', 'STAR ENTERTAINMENT', 'STAR MEDIA', 'STAR', 'STARLIGHT', 'STARZ / ANCHOR BAY', 'STARZ ANCHOR BAY', 'STARZ', 'ANCHOR BAY', 'STER KINEKOR', 'STERLING ENTERTAINMENT', 'STERLING', 'STINGRAY', 'STOCKFISCH RECORDS', 'STOCKFISCH', 'STRAND RELEASING', 'STRAND', 'STUDIO 4K', 'STUDIO CANAL', 'STUDIO GHIBLI', 'GHIBLI', 'STUDIO HAMBURG ENTERPRISES', 'HAMBURG ENTERPRISES', 'STUDIO HAMBURG', 'HAMBURG', 'STUDIO S', 'SUBKULTUR ENTERTAINMENT', 'SUBKULTUR', 'SUEVIA FILMS', 'SUEVIA', 'SUMMIT ENTERTAINMENT', 'SUMMIT', 'SUNFILM ENTERTAINMENT', 'SUNFILM', 'SURROUND RECORDS', 'SURROUND', 'SVENSK FILMINDUSTRI', 'SVENSK', 'SWEN FILMES', 'SWEN FILMS', 'SWEN', 'SYNAPSE FILMS', 'SYNAPSE', 'SYNDICADO', 'SYNERGETIC', 'T- SERIES', 'T-SERIES', 'T SERIES', 'TSERIES', 'T.V.P.', 'TVP', 'TACET RECORDS', 'TACET', 'TAI SENG', 'TAI SHENG', 'TAKEONE', 'TAKESHOBO', 'TAMASA DIFFUSION', 'TC ENTERTAINMENT', 'TC', 'TDK', 'TEAM MARKETING', 'TEATRO REAL', 'TEMA DISTRIBUCIONES', 'TEMPE DIGITAL', 'TF1 VIDÉO', 'TF1 VIDEO', 'TF1', 'THE BLU', 'BLU', 'THE ECSTASY OF FILMS', 'THE FILM DETECTIVE', 'FILM DETECTIVE', 'THE JOKERS', 'JOKERS', 'THE ON', 'ON', 'THIMFILM', 'THIM FILM', 'THIM', 'THIRD WINDOW FILMS', 'THIRD WINDOW', '3RD WINDOW FILMS', '3RD WINDOW', 'THUNDERBEAN ANIMATION', 'THUNDERBEAN', 'THUNDERBIRD RELEASING', 'THUNDERBIRD', 'TIBERIUS FILM', 'TIME LIFE', 'TIMELESS MEDIA GROUP', 'TIMELESS MEDIA', 'TIMELESS GROUP', 'TIMELESS', 'TLA RELEASING', 'TLA', 'TOBIS FILM', 'TOBIS', 'TOEI', 'TOHO', 'TOKYO SHOCK', 'TOKYO', 'TONPOOL MEDIEN GMBH', 'TONPOOL MEDIEN', 'TOPICS ENTERTAINMENT', 'TOPICS', 'TOUCHSTONE PICTURES', 'TOUCHSTONE', 'TRANSMISSION FILMS', 'TRANSMISSION', 'TRAVEL VIDEO STORE', 'TRIART', 'TRIGON FILM', 'TRIGON', 'TRINITY HOME ENTERTAINMENT', 'TRINITY ENTERTAINMENT', 'TRINITY HOME', 'TRINITY', 'TRIPICTURES', 'TRI-PICTURES', 'TRI PICTURES', 'TROMA', 'TURBINE MEDIEN', 'TURTLE RECORDS', 'TURTLE', 'TVA FILMS', 'TVA', 'TWILIGHT TIME', 'TWILIGHT', 'TT', 'TWIN CO., LTD.', 'TWIN CO, LTD.', 'TWIN CO., LTD', 'TWIN CO, LTD', 'TWIN CO LTD', 'TWIN LTD', 'TWIN CO.', 'TWIN CO', 'TWIN', 'UCA', 'UDR', 'UEK', 'UFA/DVD', 'UFA DVD', 'UFADVD', 'UGC PH', 'ULTIMATE3DHEAVEN', 'ULTRA', 'UMBRELLA ENTERTAINMENT', 'UMBRELLA', 'UMC', "UNCORK'D ENTERTAINMENT", 'UNCORKD ENTERTAINMENT', 'UNCORK D ENTERTAINMENT', "UNCORK'D", 'UNCORK D', 'UNCORKD', 'UNEARTHED FILMS', 'UNEARTHED', 'UNI DISC', 'UNIMUNDOS', 'UNITEL', 'UNIVERSAL MUSIC', 'UNIVERSAL SONY PICTURES HOME ENTERTAINMENT', 'UNIVERSAL SONY PICTURES ENTERTAINMENT', 'UNIVERSAL SONY PICTURES HOME', 'UNIVERSAL SONY PICTURES', 'UNIVERSAL HOME ENTERTAINMENT', 'UNIVERSAL ENTERTAINMENT', - 'UNIVERSAL HOME', 'UNIVERSAL STUDIOS', 'UNIVERSAL', 'UNIVERSE LASER & VIDEO CO.', 'UNIVERSE LASER AND VIDEO CO.', 'UNIVERSE LASER & VIDEO CO', 'UNIVERSE LASER AND VIDEO CO', 'UNIVERSE LASER CO.', 'UNIVERSE LASER CO', 'UNIVERSE LASER', 'UNIVERSUM FILM', 'UNIVERSUM', 'UTV', 'VAP', 'VCI', 'VENDETTA FILMS', 'VENDETTA', 'VERSÁTIL HOME VIDEO', 'VERSÁTIL VIDEO', 'VERSÁTIL HOME', 'VERSÁTIL', 'VERSATIL HOME VIDEO', 'VERSATIL VIDEO', 'VERSATIL HOME', 'VERSATIL', 'VERTICAL ENTERTAINMENT', 'VERTICAL', 'VÉRTICE 360º', 'VÉRTICE 360', 'VERTICE 360o', 'VERTICE 360', 'VERTIGO BERLIN', 'VÉRTIGO FILMS', 'VÉRTIGO', 'VERTIGO FILMS', 'VERTIGO', 'VERVE PICTURES', 'VIA VISION ENTERTAINMENT', 'VIA VISION', 'VICOL ENTERTAINMENT', 'VICOL', 'VICOM', 'VICTOR ENTERTAINMENT', 'VICTOR', 'VIDEA CDE', 'VIDEO FILM EXPRESS', 'VIDEO FILM', 'VIDEO EXPRESS', 'VIDEO MUSIC, INC.', 'VIDEO MUSIC, INC', 'VIDEO MUSIC INC.', 'VIDEO MUSIC INC', 'VIDEO MUSIC', 'VIDEO SERVICE CORP.', 'VIDEO SERVICE CORP', 'VIDEO SERVICE', 'VIDEO TRAVEL', 'VIDEOMAX', 'VIDEO MAX', 'VII PILLARS ENTERTAINMENT', 'VII PILLARS', 'VILLAGE FILMS', 'VINEGAR SYNDROME', 'VINEGAR', 'VS', 'VINNY MOVIES', 'VINNY', 'VIRGIL FILMS & ENTERTAINMENT', 'VIRGIL FILMS AND ENTERTAINMENT', 'VIRGIL ENTERTAINMENT', 'VIRGIL FILMS', 'VIRGIL', 'VIRGIN RECORDS', 'VIRGIN', 'VISION FILMS', 'VISION', 'VISUAL ENTERTAINMENT GROUP', - 'VISUAL GROUP', 'VISUAL ENTERTAINMENT', 'VISUAL', 'VIVENDI VISUAL ENTERTAINMENT', 'VIVENDI VISUAL', 'VIVENDI', 'VIZ PICTURES', 'VIZ', 'VLMEDIA', 'VL MEDIA', 'VL', 'VOLGA', 'VVS FILMS', 'VVS', 'VZ HANDELS GMBH', 'VZ HANDELS', 'WARD RECORDS', 'WARD', 'WARNER BROS.', 'WARNER BROS', 'WARNER ARCHIVE', 'WARNER ARCHIVE COLLECTION', 'WAC', 'WARNER', 'WARNER MUSIC', 'WEA', 'WEINSTEIN COMPANY', 'WEINSTEIN', 'WELL GO USA', 'WELL GO', 'WELTKINO FILMVERLEIH', 'WEST VIDEO', 'WEST', 'WHITE PEARL MOVIES', 'WHITE PEARL', 'WICKED-VISION MEDIA', 'WICKED VISION MEDIA', 'WICKEDVISION MEDIA', 'WICKED-VISION', 'WICKED VISION', 'WICKEDVISION', 'WIENERWORLD', 'WILD BUNCH', 'WILD EYE RELEASING', 'WILD EYE', 'WILD SIDE VIDEO', 'WILD SIDE', 'WME', 'WOLFE VIDEO', 'WOLFE', 'WORD ON FIRE', 'WORKS FILM GROUP', 'WORLD WRESTLING', 'WVG MEDIEN', 'WWE STUDIOS', 'WWE', 'X RATED KULT', 'X-RATED KULT', 'X RATED CULT', 'X-RATED CULT', 'X RATED', 'X-RATED', 'XCESS', 'XLRATOR', 'XT VIDEO', 'XT', 'YAMATO VIDEO', 'YAMATO', 'YASH RAJ FILMS', 'YASH RAJS', 'ZEITGEIST FILMS', 'ZEITGEIST', 'ZENITH PICTURES', 'ZENITH', 'ZIMA', 'ZYLO', 'ZYX MUSIC', 'ZYX', - 'MASTERS OF CINEMA', 'MOC' - ] - distributor_out = "" - if distributor_in not in [None, "None", ""]: - for each in distributor_list: - if distributor_in.upper() == each: - distributor_out = each - return distributor_out - - - def get_video_codec(self, bdinfo): - codecs = { - "MPEG-2 Video" : "MPEG-2", - "MPEG-4 AVC Video" : "AVC", - "MPEG-H HEVC Video" : "HEVC", - "VC-1 Video" : "VC-1" - } - codec = codecs.get(bdinfo['video'][0]['codec'], "") - return codec - - def get_video_encode(self, mi, type, bdinfo): - video_encode = "" - codec = "" - bit_depth = '0' - has_encode_settings = False - try: - format = mi['media']['track'][1]['Format'] - format_profile = mi['media']['track'][1].get('Format_Profile', format) - if mi['media']['track'][1].get('Encoded_Library_Settings', None): - has_encode_settings = True - bit_depth = mi['media']['track'][1].get('BitDepth', '0') - except: - format = bdinfo['video'][0]['codec'] - format_profile = bdinfo['video'][0]['profile'] - if type in ("ENCODE", "WEBRIP"): #ENCODE or WEBRIP - if format == 'AVC': - codec = 'x264' - elif format == 'HEVC': - codec = 'x265' - elif type in ('WEBDL', 'HDTV'): #WEB-DL - if format == 'AVC': - codec = 'H.264' - elif format == 'HEVC': - codec = 'H.265' - - if type == 'HDTV' and has_encode_settings == True: - codec = codec.replace('H.', 'x') - elif format == "VP9": - codec = "VP9" - elif format == "VC-1": - codec = "VC-1" - if format_profile == 'High 10': - profile = "Hi10P" + if not meta['tag'].startswith('-') and meta['tag'] != "": + meta['tag'] = f"-{meta['tag']}" + + meta = await tag_override(meta) + + if meta['tag'][1:].startswith(meta['channels']): + meta['tag'] = meta['tag'].replace(f"-{meta['channels']}", '') + + if meta.get('no_tag', False): + meta['tag'] = "" + + if meta.get('tag') == "-SubsPlease": # SubsPlease-specific + tracks = meta.get('mediainfo', {}).get('media', {}).get('track', []) # Get all tracks + bitrate = tracks[1].get('BitRate', '') if len(tracks) > 1 and not isinstance(tracks[1].get('BitRate', ''), dict) else '' # Check that bitrate is not a dict + bitrate_oldMediaInfo = tracks[0].get('OverallBitRate', '') if len(tracks) > 0 and not isinstance(tracks[0].get('OverallBitRate', ''), dict) else '' # Check for old MediaInfo + meta['episode_title'] = "" + if (bitrate.isdigit() and int(bitrate) >= 8000000) or (bitrate_oldMediaInfo.isdigit() and int(bitrate_oldMediaInfo) >= 8000000) and meta.get('resolution') == "1080p": # 8Mbps for 1080p + meta['service'] = "CR" + elif (bitrate.isdigit() or bitrate_oldMediaInfo.isdigit()) and meta.get('resolution') == "1080p": # Only assign if at least one bitrate is present, otherwise leave it to user + meta['service'] = "HIDI" + elif (bitrate.isdigit() and int(bitrate) >= 4000000) or (bitrate_oldMediaInfo.isdigit() and int(bitrate_oldMediaInfo) >= 4000000) and meta.get('resolution') == "720p": # 4Mbps for 720p + meta['service'] = "CR" + elif (bitrate.isdigit() or bitrate_oldMediaInfo.isdigit()) and meta.get('resolution') == "720p": + meta['service'] = "HIDI" + + if meta.get('service', None) in (None, ''): + meta['service'], meta['service_longname'] = await get_service(video, meta.get('tag', ''), meta['audio'], meta['filename']) + elif meta.get('service'): + services = await get_service(get_services_only=True) + meta['service_longname'] = max((k for k, v in services.items() if v == meta['service']), key=len, default=meta['service']) + + # return duplicate ids so I don't have to catch every site file + # this has the other advantage of stringing imdb for this object + meta['tmdb'] = meta.get('tmdb_id') + if int(meta.get('imdb_id')) != 0: + imdb_str = str(meta['imdb_id']).zfill(7) + meta['imdb'] = imdb_str else: - profile = "" - video_encode = f"{profile} {codec}" - video_codec = format - if video_codec == "MPEG Video": - video_codec = f"MPEG-{mi['media']['track'][1].get('Format_Version')}" - return video_encode, video_codec, has_encode_settings, bit_depth - - - def get_edition(self, video, bdinfo, filelist, manual_edition): - if video.lower().startswith('dc'): - video = video.replace('dc', '', 1) - guess = guessit(video) - tag = guess.get('release_group', 'NOGROUP') - repack = "" - edition = "" - if bdinfo != None: - try: - edition = guessit(bdinfo['label'])['edition'] - except: - edition = "" - else: - try: - edition = guess['edition'] - except: - edition = "" - if isinstance(edition, list): - # time.sleep(2) - edition = " ".join(edition) - if len(filelist) == 1: - video = os.path.basename(video) - - video = video.upper().replace('.', ' ').replace(tag, '').replace('-', '') - - if "OPEN MATTE" in video: - edition = edition + "Open Matte" - - if manual_edition != None: - if isinstance(manual_edition, list): - manual_edition = " ".join(manual_edition) - edition = str(manual_edition) - - if " REPACK " in (video or edition) or "V2" in video: - repack = "REPACK" - if " REPACK2 " in (video or edition) or "V3" in video: - repack = "REPACK2" - if " REPACK3 " in (video or edition) or "V4" in video: - repack = "REPACK3" - if " PROPER " in (video or edition): - repack = "PROPER" - if " RERIP " in (video.upper() or edition): - repack = "RERIP" - # if "HYBRID" in video.upper() and "HYBRID" not in title.upper(): - # edition = "Hybrid " + edition - edition = re.sub("(REPACK\d?)?(RERIP)?(PROPER)?", "", edition, flags=re.IGNORECASE).strip() - bad = ['internal', 'limited', 'retail'] - - if edition.lower() in bad: - edition = "" - # try: - # other = guess['other'] - # except: - # other = "" - # if " 3D " in other: - # edition = edition + " 3D " - # if edition == None or edition == None: - # edition = "" - return edition, repack - - - + meta['imdb'] = '0' + meta['mal'] = meta.get('mal_id') + meta['tvdb'] = meta.get('tvdb_id') + meta['tvmaze'] = meta.get('tvmaze_id') + # we finished the metadata, time it + if meta['debug']: + meta_finish_time = time.time() + console.print(f"Metadata processed in {meta_finish_time - meta_start_time:.2f} seconds") - """ - Create Torrent - """ - def create_torrent(self, meta, path, output_filename, piece_size_max): - piece_size_max = int(piece_size_max) if piece_size_max is not None else 0 - if meta['isdir'] == True: - os.chdir(path) - globs = glob.glob1(path, "*.mkv") + glob.glob1(path, "*.mp4") + glob.glob1(path, "*.ts") - no_sample_globs = [] - for file in globs: - if not file.lower().endswith('sample.mkv') or "!sample" in file.lower(): - no_sample_globs.append(os.path.abspath(f"{path}{os.sep}{file}")) - if len(no_sample_globs) == 1: - path = meta['filelist'][0] - if meta['is_disc']: - include, exclude = "", "" - else: - exclude = ["*.*", "*sample.mkv", "!sample*.*"] - include = ["*.mkv", "*.mp4", "*.ts"] - torrent = Torrent(path, - trackers = ["https://fake.tracker"], - source = "L4G", - private = True, - exclude_globs = exclude or [], - include_globs = include or [], - creation_date = datetime.now(), - comment = "Created by L4G's Upload Assistant", - created_by = "L4G's Upload Assistant") - file_size = torrent.size - if file_size < 268435456: # 256 MiB File / 256 KiB Piece Size - piece_size = 18 - piece_size_text = "256KiB" - elif file_size < 1073741824: # 1 GiB File/512 KiB Piece Size - piece_size = 19 - piece_size_text = "512KiB" - elif file_size < 2147483648 or piece_size_max == 1: # 2 GiB File/1 MiB Piece Size - piece_size = 20 - piece_size_text = "1MiB" - elif file_size < 4294967296 or piece_size_max == 2: # 4 GiB File/2 MiB Piece Size - piece_size = 21 - piece_size_text = "2MiB" - elif file_size < 8589934592 or piece_size_max == 4: # 8 GiB File/4 MiB Piece Size - piece_size = 22 - piece_size_text = "4MiB" - elif file_size < 17179869184 or piece_size_max == 8: # 16 GiB File/8 MiB Piece Size - piece_size = 23 - piece_size_text = "8MiB" - else: # 16MiB Piece Size - piece_size = 24 - piece_size_text = "16MiB" - console.print(f"[bold yellow]Creating .torrent with a piece size of {piece_size_text}... (No valid --torrenthash was provided to reuse)") - if meta.get('torrent_creation') != None: - torrent_creation = meta['torrent_creation'] - else: - torrent_creation = self.config['DEFAULT'].get('torrent_creation', 'torf') - if torrent_creation == 'torrenttools': - args = ['torrenttools', 'create', '-a', 'https://fake.tracker', '--private', 'on', '--piece-size', str(2**piece_size), '--created-by', "L4G's Upload Assistant", '--no-cross-seed','-o', f"{meta['base_dir']}/tmp/{meta['uuid']}/{output_filename}.torrent"] - if not meta['is_disc']: - args.extend(['--include', '^.*\.(mkv|mp4|ts)$']) - args.append(path) - err = subprocess.call(args) - if err != 0: - args[3] = "OMITTED" - console.print(f"[bold red]Process execution {args} returned with error code {err}.") - elif torrent_creation == 'mktorrent': - args = ['mktorrent', '-a', 'https://fake.tracker', '-p', f'-l {piece_size}', '-o', f"{meta['base_dir']}/tmp/{meta['uuid']}/{output_filename}.torrent", path] - err = subprocess.call(args) - if err != 0: - args[2] = "OMITTED" - console.print(f"[bold red]Process execution {args} returned with error code {err}.") - else: - torrent.piece_size = 2**piece_size - torrent.piece_size_max = 16777216 - torrent.generate(callback=self.torf_cb, interval=5) - torrent.write(f"{meta['base_dir']}/tmp/{meta['uuid']}/{output_filename}.torrent", overwrite=True) - torrent.verify_filesize(path) - console.print("[bold green].torrent created", end="\r") - return torrent - - - def torf_cb(self, torrent, filepath, pieces_done, pieces_total): - # print(f'{pieces_done/pieces_total*100:3.0f} % done') - cli_ui.info_progress("Hashing...", pieces_done, pieces_total) - - def create_random_torrents(self, base_dir, uuid, num, path): - manual_name = re.sub("[^0-9a-zA-Z\[\]\'\-]+", ".", os.path.basename(path)) - base_torrent = Torrent.read(f"{base_dir}/tmp/{uuid}/BASE.torrent") - for i in range(1, int(num) + 1): - new_torrent = base_torrent - new_torrent.metainfo['info']['entropy'] = random.randint(1, 999999) - Torrent.copy(new_torrent).write(f"{base_dir}/tmp/{uuid}/[RAND-{i}]{manual_name}.torrent", overwrite=True) - - def create_base_from_existing_torrent(self, torrentpath, base_dir, uuid): - if os.path.exists(torrentpath): - base_torrent = Torrent.read(torrentpath) - base_torrent.creation_date = datetime.now() - base_torrent.trackers = ['https://fake.tracker'] - base_torrent.comment = "Created by L4G's Upload Assistant" - base_torrent.created_by = "Created by L4G's Upload Assistant" - #Remove Un-whitelisted info from torrent - for each in list(base_torrent.metainfo['info']): - if each not in ('files', 'length', 'name', 'piece length', 'pieces', 'private', 'source'): - base_torrent.metainfo['info'].pop(each, None) - for each in list(base_torrent.metainfo): - if each not in ('announce', 'comment', 'creation date', 'created by', 'encoding', 'info'): - base_torrent.metainfo.pop(each, None) - base_torrent.source = 'L4G' - base_torrent.private = True - Torrent.copy(base_torrent).write(f"{base_dir}/tmp/{uuid}/BASE.torrent", overwrite=True) + return meta + async def get_cat(self, video, meta): + if meta.get('manual_category'): + return meta.get('manual_category').upper() + path_patterns = [ + r'(?i)[\\/](?:tv|tvshows|tv.shows|series|shows)[\\/]', + r'(?i)[\\/](?:season\s*\d+|s\d+)[\\/]', + r'(?i)[\\/](?:s\d{1,2}e\d{1,2}|s\d{1,2}|season\s*\d+)', + r'(?i)(?:tv pack|season\s*\d+)' + ] + filename_patterns = [ + r'(?i)s\d{1,2}e\d{1,2}', + r'(?i)s\d{1,2}', + r'(?i)\d{1,2}x\d{2}', + r'(?i)(?:season|series)\s*\d+', + r'(?i)e\d{2,3}\s*\-' + ] - """ - Upload Screenshots - """ - def upload_screens(self, meta, screens, img_host_num, i, total_screens, custom_img_list, return_dict): - # if int(total_screens) != 0 or len(meta.get('image_list', [])) > total_screens: - # if custom_img_list == []: - # console.print('[yellow]Uploading Screens') - os.chdir(f"{meta['base_dir']}/tmp/{meta['uuid']}") - img_host = self.config['DEFAULT'][f'img_host_{img_host_num}'] - if img_host != self.img_host and meta.get('imghost', None) == None: - img_host = self.img_host - i -= 1 - elif img_host_num == 1 and meta.get('imghost') != img_host: - img_host = meta.get('imghost') - img_host_num = 0 - image_list = [] - newhost_list = [] - if custom_img_list != []: - image_glob = custom_img_list - existing_images = [] - else: - image_glob = glob.glob("*.png") - if 'POSTER.png' in image_glob: - image_glob.remove('POSTER.png') - existing_images = meta.get('image_list', []) - if len(existing_images) < total_screens: - if img_host == 'imgbox': - nest_asyncio.apply() - console.print("[green]Uploading Screens to Imgbox...") - image_list = asyncio.run(self.imgbox_upload(f"{meta['base_dir']}/tmp/{meta['uuid']}", image_glob)) - if image_list == []: - if img_host_num == 0: - img_host_num = 1 - console.print("[yellow]Imgbox failed, trying next image host") - image_list, i = self.upload_screens(meta, screens - i , img_host_num + 1, i, total_screens, [], return_dict) - else: - with Progress( - TextColumn("[bold green]Uploading Screens..."), - BarColumn(), - "[cyan]{task.completed}/{task.total}", - TimeRemainingColumn() - ) as progress: - upload_task = progress.add_task(f"[green]Uploading Screens to {img_host}...", total = len(image_glob[-screens:])) - timeout=60 - for image in image_glob[-screens:]: - if img_host == "imgbb": - url = "https://api.imgbb.com/1/upload" - data = { - 'key': self.config['DEFAULT']['imgbb_api'], - 'image': base64.b64encode(open(image, "rb").read()).decode('utf8') - } - try: - response = requests.post(url, data = data,timeout=timeout) - response = response.json() - if response.get('success') != True: - progress.console.print(response) - img_url = response['data'].get('medium', response['data']['image'])['url'] - web_url = response['data']['url_viewer'] - raw_url = response['data']['image']['url'] - except Exception: - progress.console.print("[yellow]imgbb failed, trying next image host") - progress.stop() - newhost_list, i = self.upload_screens(meta, screens - i , img_host_num + 1, i, total_screens, [], return_dict) - elif img_host == "freeimage.host": - progress.console.print("[red]Support for freeimage.host has been removed. Please remove from your config") - progress.console.print("continuing in 15 seconds") - time.sleep(15) - progress.stop() - newhost_list, i = self.upload_screens(meta, screens - i, img_host_num + 1, i, total_screens, [], return_dict) - elif img_host == "pixhost": - url = "https://api.pixhost.to/images" - data = { - 'content_type': '0', - 'max_th_size': 350, - } - files = { - 'img': ('file-upload[0]', open(image, 'rb')), - } - try: - response = requests.post(url, data=data, files=files,timeout=timeout) - if response.status_code != 200: - progress.console.print(response) - response = response.json() - raw_url = response['th_url'].replace('https://t', 'https://img').replace('/thumbs/', '/images/') - img_url = response['th_url'] - web_url = response['show_url'] - except Exception: - progress.console.print("[yellow]pixhost failed, trying next image host") - progress.stop() - newhost_list, i = self.upload_screens(meta, screens - i , img_host_num + 1, i, total_screens, [], return_dict) - elif img_host == "ptpimg": - payload = { - 'format' : 'json', - 'api_key' : self.config['DEFAULT']['ptpimg_api'] # API key is obtained from inspecting element on the upload page. - } - files = [('file-upload[0]', open(image, 'rb'))] - headers = { 'referer': 'https://ptpimg.me/index.php'} - url = "https://ptpimg.me/upload.php" - - # tasks.append(asyncio.ensure_future(self.upload_image(session, url, data, headers, files=None))) - try: - response = requests.post("https://ptpimg.me/upload.php", headers=headers, data=payload, files=files) - response = response.json() - ptpimg_code = response[0]['code'] - ptpimg_ext = response[0]['ext'] - img_url = f"https://ptpimg.me/{ptpimg_code}.{ptpimg_ext}" - web_url = f"https://ptpimg.me/{ptpimg_code}.{ptpimg_ext}" - raw_url = f"https://ptpimg.me/{ptpimg_code}.{ptpimg_ext}" - except: - progress.console.print("[yellow]ptpimg failed, trying next image host") - progress.stop() - newhost_list, i = self.upload_screens(meta, screens - i, img_host_num + 1, i, total_screens, [], return_dict) - elif img_host == "lensdump": - url = "https://lensdump.com/api/1/upload" - data = { - 'image': base64.b64encode(open(image, "rb").read()).decode('utf8') - } - headers = { - 'X-API-Key': self.config['DEFAULT']['lensdump_api'], - } - try: - response = requests.post(url, data=data, headers=headers, timeout=timeout) - response = response.json() - if response.get('status_code') != 200: - progress.console.print(response) - img_url = response['data'].get('medium', response['data']['image'])['url'] - web_url = response['data']['url_viewer'] - raw_url = response['data']['image']['url'] - except Exception: - progress.console.print("[yellow]lensdump failed, trying next image host") - progress.stop() - newhost_list, i = self.upload_screens(meta, screens - i , img_host_num + 1, i, total_screens, [], return_dict) - else: - console.print("[bold red]Please choose a supported image host in your config") - exit() - - - - if len(newhost_list) >=1: - image_list.extend(newhost_list) - else: - image_dict = {} - image_dict['web_url'] = web_url - image_dict['img_url'] = img_url - image_dict['raw_url'] = raw_url - image_list.append(image_dict) - # cli_ui.info_count(i, total_screens, "Uploaded") - progress.advance(upload_task) - i += 1 - time.sleep(0.5) - if i >= total_screens: - break - return_dict['image_list'] = image_list - return image_list, i - else: - return meta.get('image_list', []), total_screens - - async def imgbox_upload(self, chdir, image_glob): - os.chdir(chdir) - image_list = [] - # image_glob = glob.glob("*.png") - async with pyimgbox.Gallery(thumb_width=350, square_thumbs=False) as gallery: - async for submission in gallery.add(image_glob): - if not submission['success']: - console.print(f"[red]There was an error uploading to imgbox: [yellow]{submission['error']}[/yellow][/red]") - return [] - else: - image_dict = {} - image_dict['web_url'] = submission['web_url'] - image_dict['img_url'] = submission['thumbnail_url'] - image_dict['raw_url'] = submission['image_url'] - image_list.append(image_dict) - return image_list - - - - - - - async def get_name(self, meta): - type = meta.get('type', "") - title = meta.get('title',"") - alt_title = meta.get('aka', "") - year = meta.get('year', "") - resolution = meta.get('resolution', "") - if resolution == "OTHER": - resolution = "" - audio = meta.get('audio', "") - service = meta.get('service', "") - season = meta.get('season', "") - episode = meta.get('episode', "") - part = meta.get('part', "") - repack = meta.get('repack', "") - three_d = meta.get('3D', "") - tag = meta.get('tag', "") - source = meta.get('source', "") - uhd = meta.get('uhd', "") - hdr = meta.get('hdr', "") - episode_title = meta.get('episode_title', '') - if meta.get('is_disc', "") == "BDMV": #Disk - video_codec = meta.get('video_codec', "") - region = meta.get('region', "") - elif meta.get('is_disc', "") == "DVD": - region = meta.get('region', "") - dvd_size = meta.get('dvd_size', "") - else: - video_codec = meta.get('video_codec', "") - video_encode = meta.get('video_encode', "") - edition = meta.get('edition', "") + path = meta.get('path', '') + uuid = meta.get('uuid', '') - if meta['category'] == "TV": - if meta['search_year'] != "": - year = meta['year'] - else: - year = "" - if meta.get('no_season', False) == True: - season = '' - if meta.get('no_year', False) == True: - year = '' - if meta.get('no_aka', False) == True: - alt_title = '' - if meta['debug']: - console.log("[cyan]get_name cat/type") - console.log(f"CATEGORY: {meta['category']}") - console.log(f"TYPE: {meta['type']}") - console.log("[cyan]get_name meta:") - console.log(meta) - - #YAY NAMING FUN - if meta['category'] == "MOVIE": #MOVIE SPECIFIC - if type == "DISC": #Disk - if meta['is_disc'] == 'BDMV': - name = f"{title} {alt_title} {year} {three_d} {edition} {repack} {resolution} {region} {uhd} {source} {hdr} {video_codec} {audio}" - potential_missing = ['edition', 'region', 'distributor'] - elif meta['is_disc'] == 'DVD': - name = f"{title} {alt_title} {year} {edition} {repack} {source} {dvd_size} {audio}" - potential_missing = ['edition', 'distributor'] - elif meta['is_disc'] == 'HDDVD': - name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {source} {video_codec} {audio}" - potential_missing = ['edition', 'region', 'distributor'] - elif type == "REMUX" and source in ("BluRay", "HDDVD"): #BluRay/HDDVD Remux - name = f"{title} {alt_title} {year} {three_d} {edition} {repack} {resolution} {uhd} {source} REMUX {hdr} {video_codec} {audio}" - potential_missing = ['edition', 'description'] - elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD"): #DVD Remux - name = f"{title} {alt_title} {year} {edition} {repack} {source} REMUX {audio}" - potential_missing = ['edition', 'description'] - elif type == "ENCODE": #Encode - name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {uhd} {source} {audio} {hdr} {video_encode}" - potential_missing = ['edition', 'description'] - elif type == "WEBDL": #WEB-DL - name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {uhd} {service} WEB-DL {audio} {hdr} {video_encode}" - potential_missing = ['edition', 'service'] - elif type == "WEBRIP": #WEBRip - name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {uhd} {service} WEBRip {audio} {hdr} {video_encode}" - potential_missing = ['edition', 'service'] - elif type == "HDTV": #HDTV - name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {source} {audio} {video_encode}" - potential_missing = [] - elif meta['category'] == "TV": #TV SPECIFIC - if type == "DISC": #Disk - if meta['is_disc'] == 'BDMV': - name = f"{title} {year} {alt_title} {season}{episode} {three_d} {edition} {repack} {resolution} {region} {uhd} {source} {hdr} {video_codec} {audio}" - potential_missing = ['edition', 'region', 'distributor'] - if meta['is_disc'] == 'DVD': - name = f"{title} {alt_title} {season}{episode}{three_d} {edition} {repack} {source} {dvd_size} {audio}" - potential_missing = ['edition', 'distributor'] - elif meta['is_disc'] == 'HDDVD': - name = f"{title} {alt_title} {year} {edition} {repack} {resolution} {source} {video_codec} {audio}" - potential_missing = ['edition', 'region', 'distributor'] - elif type == "REMUX" and source in ("BluRay", "HDDVD"): #BluRay Remux - name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {three_d} {edition} {repack} {resolution} {uhd} {source} REMUX {hdr} {video_codec} {audio}" #SOURCE - potential_missing = ['edition', 'description'] - elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD"): #DVD Remux - name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {repack} {source} REMUX {audio}" #SOURCE - potential_missing = ['edition', 'description'] - elif type == "ENCODE": #Encode - name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {repack} {resolution} {uhd} {source} {audio} {hdr} {video_encode}" #SOURCE - potential_missing = ['edition', 'description'] - elif type == "WEBDL": #WEB-DL - name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {repack} {resolution} {uhd} {service} WEB-DL {audio} {hdr} {video_encode}" - potential_missing = ['edition', 'service'] - elif type == "WEBRIP": #WEBRip - name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {repack} {resolution} {uhd} {service} WEBRip {audio} {hdr} {video_encode}" - potential_missing = ['edition', 'service'] - elif type == "HDTV": #HDTV - name = f"{title} {year} {alt_title} {season}{episode} {episode_title} {part} {edition} {repack} {resolution} {source} {audio} {video_encode}" - potential_missing = [] - - - try: - name = ' '.join(name.split()) - except: - console.print("[bold red]Unable to generate name. Please re-run and correct any of the following args if needed.") - console.print(f"--category [yellow]{meta['category']}") - console.print(f"--type [yellow]{meta['type']}") - console.print(f"--source [yellow]{meta['source']}") - - exit() - name_notag = name - name = name_notag + tag - clean_name = self.clean_filename(name) - return name_notag, name, clean_name, potential_missing - - - - - async def get_season_episode(self, video, meta): - if meta['category'] == 'TV': - filelist = meta['filelist'] - meta['tv_pack'] = 0 - is_daily = False - if meta['anime'] == False: - try: - if meta.get('manual_date'): - raise ManualDateException - try: - guess_year = guessit(video)['year'] - except Exception: - guess_year = "" - if guessit(video)["season"] == guess_year: - if f"s{guessit(video)['season']}" in video.lower(): - season_int = str(guessit(video)["season"]) - season = "S" + season_int.zfill(2) - else: - season_int = "1" - season = "S01" - else: - season_int = str(guessit(video)["season"]) - season = "S" + season_int.zfill(2) + for pattern in path_patterns: + if re.search(pattern, path): + return "TV" - except Exception: - try: - guess_date = meta.get('manual_date', guessit(video)['date']) if meta.get('manual_date') else guessit(video)['date'] - season_int, episode_int = self.daily_to_tmdb_season_episode(meta.get('tmdb'), guess_date) - # season = f"S{season_int.zfill(2)}" - # episode = f"E{episode_int.zfill(2)}" - season = str(guess_date) - episode = "" - is_daily = True - except Exception: - console.print_exception() - season_int = "1" - season = "S01" - try: - if is_daily != True: - episodes = "" - if len(filelist) == 1: - episodes = guessit(video)['episode'] - if type(episodes) == list: - episode = "" - for item in guessit(video)["episode"]: - ep = (str(item).zfill(2)) - episode += f"E{ep}" - episode_int = episodes[0] - else: - episode_int = str(episodes) - episode = "E" + str(episodes).zfill(2) - else: - episode = "" - episode_int = "0" - meta['tv_pack'] = 1 - except Exception: - episode = "" - episode_int = "0" - meta['tv_pack'] = 1 - else: - #If Anime - parsed = anitopy.parse(Path(video).name) - # romaji, mal_id, eng_title, seasonYear, anilist_episodes = self.get_romaji(guessit(parsed['anime_title'], {"excludes" : ["country", "language"]})['title']) - romaji, mal_id, eng_title, seasonYear, anilist_episodes = self.get_romaji(parsed['anime_title'], meta.get('mal', None)) - if mal_id: - meta['mal_id'] = mal_id - if meta.get('tmdb_manual', None) == None: - year = parsed.get('anime_year', str(seasonYear)) - meta = await self.get_tmdb_id(guessit(parsed['anime_title'], {"excludes" : ["country", "language"]})['title'], year, meta, meta['category']) - meta = await self.tmdb_other_meta(meta) - if meta['category'] != "TV": - return meta - - # meta['title'] = eng_title - # difference = SequenceMatcher(None, eng_title, romaji.lower()).ratio() - # if difference >= 0.8: - # meta['aka'] = "" - # else: - # meta['aka'] = f" AKA {romaji}" - tag = parsed.get('release_group', "") - if tag != "": - meta['tag'] = f"-{tag}" - if len(filelist) == 1: - try: - episodes = parsed.get('episode_number', guessit(video).get('episode', '1')) - if not isinstance(episodes, list) and not episodes.isnumeric(): - episodes = guessit(video)['episode'] - if type(episodes) == list: - episode = "" - for item in episodes: - ep = (str(item).zfill(2)) - episode += f"E{ep}" - episode_int = episodes[0] - else: - episode_int = str(int(episodes)) - episode = f"E{str(int(episodes)).zfill(2)}" - except Exception: - episode = "E01" - episode_int = "1" - console.print('[bold yellow]There was an error guessing the episode number. Guessing E01. Use [bold green]--episode #[/bold green] to correct if needed') - await asyncio.sleep(1.5) - else: - episode = "" - episode_int = "0" - meta['tv_pack'] = 1 - - try: - if meta.get('season_int'): - season = meta.get('season_int') - else: - season = parsed.get('anime_season', guessit(video)['season']) - season_int = season - season = f"S{season.zfill(2)}" - except Exception: - try: - if int(episode_int) >= anilist_episodes: - params = { - 'id' : str(meta['tvdb_id']), - 'origin' : 'tvdb', - 'absolute' : str(episode_int), - # 'destination' : 'tvdb' - } - url = "https://thexem.info/map/single" - response = requests.post(url, params=params).json() - if response['result'] == "failure": - raise XEMNotFound - if meta['debug']: - console.log(f"[cyan]TheXEM Absolute -> Standard[/cyan]\n{response}") - season_int = str(response['data']['scene']['season']) - season = f"S{str(response['data']['scene']['season']).zfill(2)}" - if len(filelist) == 1: - episode_int = str(response['data']['scene']['episode']) - episode = f"E{str(response['data']['scene']['episode']).zfill(2)}" - else: - #Get season from xem name map - season = "S01" - season_int = "1" - names_url = f"https://thexem.info/map/names?origin=tvdb&id={str(meta['tvdb_id'])}" - names_response = requests.get(names_url).json() - if meta['debug']: - console.log(f'[cyan]Matching Season Number from TheXEM\n{names_response}') - difference = 0 - if names_response['result'] == "success": - for season_num, values in names_response['data'].items(): - for lang, names in values.items(): - if lang == "jp": - for name in names: - romaji_check = re.sub("[^0-9a-zA-Z\[\]]+", "", romaji.lower().replace(' ', '')) - name_check = re.sub("[^0-9a-zA-Z\[\]]+", "", name.lower().replace(' ', '')) - diff = SequenceMatcher(None, romaji_check, name_check).ratio() - if romaji_check in name_check: - if diff >= difference: - if season_num != "all": - season_int = season_num - season = f"S{season_num.zfill(2)}" - else: - season_int = "1" - season = "S01" - difference = diff - if lang == "us": - for name in names: - eng_check = re.sub("[^0-9a-zA-Z\[\]]+", "", eng_title.lower().replace(' ', '')) - name_check = re.sub("[^0-9a-zA-Z\[\]]+", "", name.lower().replace(' ', '')) - diff = SequenceMatcher(None, eng_check, name_check).ratio() - if eng_check in name_check: - if diff >= difference: - if season_num != "all": - season_int = season_num - season = f"S{season_num.zfill(2)}" - else: - season_int = "1" - season = "S01" - difference = diff - else: - raise XEMNotFound - except Exception: - if meta['debug']: - console.print_exception() - try: - season = guessit(video)['season'] - season_int = season - except Exception: - season_int = "1" - season = "S01" - console.print(f"[bold yellow]{meta['title']} does not exist on thexem, guessing {season}") - console.print(f"[bold yellow]If [green]{season}[/green] is incorrect, use --season to correct") - await asyncio.sleep(3) - # try: - # version = parsed['release_version'] - # if int(version) == 2: - # meta['repack'] = "REPACK" - # elif int(version) > 2: - # meta['repack'] = f"REPACK{int(version) - 1}" - # # version = f"v{version}" - # except Exception: - # # version = "" - # pass - - if meta.get('manual_season', None) == None: - meta['season'] = season - else: - season_int = meta['manual_season'].lower().replace('s', '') - meta['season'] = f"S{meta['manual_season'].lower().replace('s', '').zfill(2)}" - if meta.get('manual_episode', None) == None: - meta['episode'] = episode - else: - episode_int = meta['manual_episode'].lower().replace('e', '') - meta['episode'] = f"E{meta['manual_episode'].lower().replace('e', '').zfill(2)}" - meta['tv_pack'] = 0 - - # if " COMPLETE " in Path(video).name.replace('.', ' '): - # meta['season'] = "COMPLETE" - meta['season_int'] = season_int - meta['episode_int'] = episode_int - - - meta['episode_title_storage'] = guessit(video,{"excludes" : "part"}).get('episode_title', '') - if meta['season'] == "S00" or meta['episode'] == "E00": - meta['episode_title'] = meta['episode_title_storage'] - - # Guess the part of the episode (if available) - meta['part'] = "" - if meta['tv_pack'] == 1: - part = guessit(os.path.dirname(video)).get('part') - meta['part'] = f"Part {part}" if part else "" + for pattern in filename_patterns: + if re.search(pattern, uuid) or re.search(pattern, os.path.basename(path)): + return "TV" - return meta + if "subsplease" in path.lower() or "subsplease" in uuid.lower(): + anime_pattern = r'(?:\s-\s)?(\d{1,3})\s*\((?:\d+p|480p|480i|576i|576p|720p|1080i|1080p|2160p)\)' + if re.search(anime_pattern, path.lower()) or re.search(anime_pattern, uuid.lower()): + return "TV" + return "MOVIE" - def get_service(self, video, tag, audio, guess_title): - service = guessit(video).get('streaming_service', "") - services = { - '9NOW': '9NOW', '9Now': '9NOW', 'AE': 'AE', 'A&E': 'AE', 'AJAZ': 'AJAZ', 'Al Jazeera English': 'AJAZ', - 'ALL4': 'ALL4', 'Channel 4': 'ALL4', 'AMBC': 'AMBC', 'ABC': 'AMBC', 'AMC': 'AMC', 'AMZN': 'AMZN', - 'Amazon Prime': 'AMZN', 'ANLB': 'ANLB', 'AnimeLab': 'ANLB', 'ANPL': 'ANPL', 'Animal Planet': 'ANPL', - 'AOL': 'AOL', 'ARD': 'ARD', 'AS': 'AS', 'Adult Swim': 'AS', 'ATK': 'ATK', "America's Test Kitchen": 'ATK', - 'ATVP': 'ATVP', 'AppleTV': 'ATVP', 'AUBC': 'AUBC', 'ABC Australia': 'AUBC', 'BCORE': 'BCORE', 'BKPL': 'BKPL', - 'Blackpills': 'BKPL', 'BluTV': 'BLU', 'Binge': 'BNGE', 'BOOM': 'BOOM', 'Boomerang': 'BOOM', 'BRAV': 'BRAV', - 'BravoTV': 'BRAV', 'CBC': 'CBC', 'CBS': 'CBS', 'CC': 'CC', 'Comedy Central': 'CC', 'CCGC': 'CCGC', - 'Comedians in Cars Getting Coffee': 'CCGC', 'CHGD': 'CHGD', 'CHRGD': 'CHGD', 'CMAX': 'CMAX', 'Cinemax': 'CMAX', - 'CMOR': 'CMOR', 'CMT': 'CMT', 'Country Music Television': 'CMT', 'CN': 'CN', 'Cartoon Network': 'CN', 'CNBC': 'CNBC', - 'CNLP': 'CNLP', 'Canal+': 'CNLP', 'COOK': 'COOK', 'CORE': 'CORE', 'CR': 'CR', 'Crunchy Roll': 'CR', 'Crave': 'CRAV', - 'CRIT': 'CRIT', 'Criterion' : 'CRIT', 'CRKL': 'CRKL', 'Crackle': 'CRKL', 'CSPN': 'CSPN', 'CSpan': 'CSPN', 'CTV': 'CTV', 'CUR': 'CUR', - 'CuriosityStream': 'CUR', 'CW': 'CW', 'The CW': 'CW', 'CWS': 'CWS', 'CWSeed': 'CWS', 'DAZN': 'DAZN', 'DCU': 'DCU', - 'DC Universe': 'DCU', 'DDY': 'DDY', 'Digiturk Diledigin Yerde': 'DDY', 'DEST': 'DEST', 'DramaFever': 'DF', 'DHF': 'DHF', - 'Deadhouse Films': 'DHF', 'DISC': 'DISC', 'Discovery': 'DISC', 'DIY': 'DIY', 'DIY Network': 'DIY', 'DOCC': 'DOCC', - 'Doc Club': 'DOCC', 'DPLY': 'DPLY', 'DPlay': 'DPLY', 'DRPO': 'DRPO', 'Discovery Plus': 'DSCP', 'DSKI': 'DSKI', - 'Daisuki': 'DSKI', 'DSNP': 'DSNP', 'Disney+': 'DSNP', 'DSNY': 'DSNY', 'Disney': 'DSNY', 'DTV': 'DTV', - 'EPIX': 'EPIX', 'ePix': 'EPIX', 'ESPN': 'ESPN', 'ESQ': 'ESQ', 'Esquire': 'ESQ', 'ETTV': 'ETTV', 'El Trece': 'ETTV', - 'ETV': 'ETV', 'E!': 'ETV', 'FAM': 'FAM', 'Fandor': 'FANDOR', 'Facebook Watch': 'FBWatch', 'FJR': 'FJR', - 'Family Jr': 'FJR', 'FOOD': 'FOOD', 'Food Network': 'FOOD', 'FOX': 'FOX', 'Fox': 'FOX', 'Fox Premium': 'FOXP', - 'UFC Fight Pass': 'FP', 'FPT': 'FPT', 'FREE': 'FREE', 'Freeform': 'FREE', 'FTV': 'FTV', 'FUNI': 'FUNI', 'FUNi' : 'FUNI', - 'Foxtel': 'FXTL', 'FYI': 'FYI', 'FYI Network': 'FYI', 'GC': 'GC', 'NHL GameCenter': 'GC', 'GLBL': 'GLBL', - 'Global': 'GLBL', 'GLOB': 'GLOB', 'GloboSat Play': 'GLOB', 'GO90': 'GO90', 'GagaOOLala': 'Gaga', 'HBO': 'HBO', - 'HBO Go': 'HBO', 'HGTV': 'HGTV', 'HIDI': 'HIDI', 'HIST': 'HIST', 'History': 'HIST', 'HLMK': 'HLMK', 'Hallmark': 'HLMK', - 'HMAX': 'HMAX', 'HBO Max': 'HMAX', 'HS': 'HTSR', 'HTSR' : 'HTSR', 'HSTR': 'Hotstar', 'HULU': 'HULU', 'Hulu': 'HULU', 'hoichoi': 'HoiChoi', 'ID': 'ID', - 'Investigation Discovery': 'ID', 'IFC': 'IFC', 'iflix': 'IFX', 'National Audiovisual Institute': 'INA', 'ITV': 'ITV', - 'KAYO': 'KAYO', 'KNOW': 'KNOW', 'Knowledge Network': 'KNOW', 'KNPY': 'KNPY', 'Kanopy' : 'KNPY', 'LIFE': 'LIFE', 'Lifetime': 'LIFE', 'LN': 'LN', - 'MA' : 'MA', 'Movies Anywhere' : 'MA', 'MAX' : 'MAX', 'MBC': 'MBC', 'MNBC': 'MNBC', 'MSNBC': 'MNBC', 'MTOD': 'MTOD', 'Motor Trend OnDemand': 'MTOD', 'MTV': 'MTV', 'MUBI': 'MUBI', - 'NATG': 'NATG', 'National Geographic': 'NATG', 'NBA': 'NBA', 'NBA TV': 'NBA', 'NBC': 'NBC', 'NF': 'NF', 'Netflix': 'NF', - 'National Film Board': 'NFB', 'NFL': 'NFL', 'NFLN': 'NFLN', 'NFL Now': 'NFLN', 'NICK': 'NICK', 'Nickelodeon': 'NICK', 'NRK': 'NRK', - 'Norsk Rikskringkasting': 'NRK', 'OnDemandKorea': 'ODK', 'Opto': 'OPTO', 'Oprah Winfrey Network': 'OWN', 'PA': 'PA', 'PBS': 'PBS', - 'PBSK': 'PBSK', 'PBS Kids': 'PBSK', 'PCOK': 'PCOK', 'Peacock': 'PCOK', 'PLAY': 'PLAY', 'PLUZ': 'PLUZ', 'Pluzz': 'PLUZ', 'PMNP': 'PMNP', - 'PMNT': 'PMNT', 'PMTP' : 'PMTP', 'POGO': 'POGO', 'PokerGO': 'POGO', 'PSN': 'PSN', 'Playstation Network': 'PSN', 'PUHU': 'PUHU', 'QIBI': 'QIBI', - 'RED': 'RED', 'YouTube Red': 'RED', 'RKTN': 'RKTN', 'Rakuten TV': 'RKTN', 'The Roku Channel': 'ROKU', 'RSTR': 'RSTR', 'RTE': 'RTE', - 'RTE One': 'RTE', 'RUUTU': 'RUUTU', 'SBS': 'SBS', 'Science Channel': 'SCI', 'SESO': 'SESO', 'SeeSo': 'SESO', 'SHMI': 'SHMI', 'Shomi': 'SHMI', 'SKST' : 'SKST', 'SkyShowtime': 'SKST', - 'SHO': 'SHO', 'Showtime': 'SHO', 'SNET': 'SNET', 'Sportsnet': 'SNET', 'Sony': 'SONY', 'SPIK': 'SPIK', 'Spike': 'SPIK', 'Spike TV': 'SPKE', - 'SPRT': 'SPRT', 'Sprout': 'SPRT', 'STAN': 'STAN', 'Stan': 'STAN', 'STARZ': 'STARZ', 'STRP': 'STRP', 'Star+' : 'STRP', 'STZ': 'STZ', 'Starz': 'STZ', 'SVT': 'SVT', - 'Sveriges Television': 'SVT', 'SWER': 'SWER', 'SwearNet': 'SWER', 'SYFY': 'SYFY', 'Syfy': 'SYFY', 'TBS': 'TBS', 'TEN': 'TEN', - 'TFOU': 'TFOU', 'TFou': 'TFOU', 'TIMV': 'TIMV', 'TLC': 'TLC', 'TOU': 'TOU', 'TRVL': 'TRVL', 'TUBI': 'TUBI', 'TubiTV': 'TUBI', - 'TV3': 'TV3', 'TV3 Ireland': 'TV3', 'TV4': 'TV4', 'TV4 Sweeden': 'TV4', 'TVING': 'TVING', 'TVL': 'TVL', 'TV Land': 'TVL', - 'TVNZ': 'TVNZ', 'UFC': 'UFC', 'UKTV': 'UKTV', 'UNIV': 'UNIV', 'Univision': 'UNIV', 'USAN': 'USAN', 'USA Network': 'USAN', - 'VH1': 'VH1', 'VIAP': 'VIAP', 'VICE': 'VICE', 'Viceland': 'VICE', 'Viki': 'VIKI', 'VIMEO': 'VIMEO', 'VLCT': 'VLCT', - 'Velocity': 'VLCT', 'VMEO': 'VMEO', 'Vimeo': 'VMEO', 'VRV': 'VRV', 'VUDU': 'VUDU', 'WME': 'WME', 'WatchMe': 'WME', 'WNET': 'WNET', - 'W Network': 'WNET', 'WWEN': 'WWEN', 'WWE Network': 'WWEN', 'XBOX': 'XBOX', 'Xbox Video': 'XBOX', 'YHOO': 'YHOO', 'Yahoo': 'YHOO', - 'YT': 'YT', 'ZDF': 'ZDF', 'iP': 'iP', 'BBC iPlayer': 'iP', 'iQIYI': 'iQIYI', 'iT': 'iT', 'iTunes': 'iT' - } - - - video_name = re.sub("[.()]", " ", video.replace(tag, '').replace(guess_title, '')) - if "DTS-HD MA" in audio: - video_name = video_name.replace("DTS-HD.MA.", "").replace("DTS-HD MA ", "") - for key, value in services.items(): - if (' ' + key + ' ') in video_name and key not in guessit(video, {"excludes" : ["country", "language"]}).get('title', ''): - service = value - elif key == service: - service = value - service_longname = service - for key, value in services.items(): - if value == service and len(key) > len(service_longname): - service_longname = key - if service_longname == "Amazon Prime": - service_longname = "Amazon" - return service, service_longname - - - - def stream_optimized(self, stream_opt): - if stream_opt == True: + async def stream_optimized(self, stream_opt): + if stream_opt is True: stream = 1 else: stream = 0 return stream - - def is_anon(self, anon_in): - anon = self.config['DEFAULT'].get("Anon", "False") - if anon.lower() == "true": - console.print("[bold red]Global ANON has been removed in favor of per-tracker settings. Please update your config accordingly.") - time.sleep(10) - if anon_in == True: - anon_out = 1 - else: - anon_out = 0 - return anon_out - - async def upload_image(self, session, url, data, headers, files): - if headers == None and files == None: - async with session.post(url=url, data=data) as resp: - response = await resp.json() - return response - elif headers == None and files != None: - async with session.post(url=url, data=data, files=files) as resp: - response = await resp.json() - return response - elif headers != None and files == None: - async with session.post(url=url, data=data, headers=headers) as resp: - response = await resp.json() - return response - else: - async with session.post(url=url, data=data, headers=headers, files=files) as resp: - response = await resp.json() - return response - - - def clean_filename(self, name): - invalid = '<>:"/\|?*' - for char in invalid: - name = name.replace(char, '-') - return name - - - async def gen_desc(self, meta): - desclink = meta.get('desclink', None) - descfile = meta.get('descfile', None) - ptp_desc = blu_desc = "" - desc_source = [] - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'w', newline="", encoding='utf8') as description: - description.seek(0) - if (desclink, descfile, meta['desc']) == (None, None, None): - if meta.get('ptp_manual') != None: - desc_source.append('PTP') - if meta.get('blu_manual') != None: - desc_source.append('BLU') - if len(desc_source) != 1: - desc_source = None - else: - desc_source = desc_source[0] - - if meta.get('ptp', None) != None and str(self.config['TRACKERS'].get('PTP', {}).get('useAPI')).lower() == "true" and desc_source in ['PTP', None]: - ptp = PTP(config=self.config) - ptp_desc = await ptp.get_ptp_description(meta['ptp'], meta['is_disc']) - if ptp_desc.replace('\r\n', '').replace('\n', '').strip() != "": - description.write(ptp_desc) - description.write("\n") - meta['description'] = 'PTP' - - if ptp_desc == "" and meta.get('blu_desc', '').rstrip() not in [None, ''] and desc_source in ['BLU', None]: - if meta.get('blu_desc', '').strip().replace('\r\n', '').replace('\n', '') != '': - description.write(meta['blu_desc']) - meta['description'] = 'BLU' - - if meta.get('desc_template', None) != None: - from jinja2 import Template - with open(f"{meta['base_dir']}/data/templates/{meta['desc_template']}.txt", 'r') as f: - desc_templater = Template(f.read()) - template_desc = desc_templater.render(meta) - if template_desc.strip() != "": - description.write(template_desc) - description.write("\n") - - if meta['nfo'] != False: - description.write("[code]") - nfo = glob.glob("*.nfo")[0] - description.write(open(nfo, 'r', encoding="utf-8").read()) - description.write("[/code]") - description.write("\n") - meta['description'] = "CUSTOM" - if desclink != None: - parsed = urllib.parse.urlparse(desclink.replace('/raw/', '/')) - split = os.path.split(parsed.path) - if split[0] != '/': - raw = parsed._replace(path=f"{split[0]}/raw/{split[1]}") - else: - raw = parsed._replace(path=f"/raw{parsed.path}") - raw = urllib.parse.urlunparse(raw) - description.write(requests.get(raw).text) - description.write("\n") - meta['description'] = "CUSTOM" - - if descfile != None: - if os.path.isfile(descfile) == True: - text = open(descfile, 'r').read() - description.write(text) - meta['description'] = "CUSTOM" - if meta['desc'] != None: - description.write(meta['desc']) - description.write("\n") - meta['description'] = "CUSTOM" - description.write("\n") - return meta - - async def tag_override(self, meta): - with open(f"{meta['base_dir']}/data/tags.json", 'r', encoding="utf-8") as f: - tags = json.load(f) - f.close() - - for tag in tags: - value = tags.get(tag) - if value.get('in_name', "") == tag and tag in meta['path']: - meta['tag'] = f"-{tag}" - if meta['tag'][1:] == tag: - for key in value: - if key == 'type': - if meta[key] == "ENCODE": - meta[key] = value.get(key) - else: - pass - elif key == 'personalrelease': - meta[key] = bool(distutils.util.strtobool(str(value.get(key, 'False')))) - elif key == 'template': - meta['desc_template'] = value.get(key) - else: - meta[key] = value.get(key) - return meta - - - async def package(self, meta): - if meta['tag'] == "": - tag = "" - else: - tag = f" / {meta['tag'][1:]}" - if meta['is_disc'] == "DVD": - res = meta['source'] - else: - res = meta['resolution'] - - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/GENERIC_INFO.txt", 'w', encoding="utf-8") as generic: - generic.write(f"Name: {meta['name']}\n\n") - generic.write(f"Overview: {meta['overview']}\n\n") - generic.write(f"{res} / {meta['type']}{tag}\n\n") - generic.write(f"Category: {meta['category']}\n") - generic.write(f"TMDB: https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}\n") - if meta['imdb_id'] != "0": - generic.write(f"IMDb: https://www.imdb.com/title/tt{meta['imdb_id']}\n") - if meta['tvdb_id'] != "0": - generic.write(f"TVDB: https://www.thetvdb.com/?id={meta['tvdb_id']}&tab=series\n") - poster_img = f"{meta['base_dir']}/tmp/{meta['uuid']}/POSTER.png" - if meta.get('poster', None) not in ['', None] and not os.path.exists(poster_img): - if meta.get('rehosted_poster', None) == None: - r = requests.get(meta['poster'], stream=True) - if r.status_code == 200: - console.print("[bold yellow]Rehosting Poster") - r.raw.decode_content = True - with open(poster_img, 'wb') as f: - shutil.copyfileobj(r.raw, f) - poster, dummy = self.upload_screens(meta, 1, 1, 0, 1, [poster_img], {}) - poster = poster[0] - generic.write(f"TMDB Poster: {poster.get('raw_url', poster.get('img_url'))}\n") - meta['rehosted_poster'] = poster.get('raw_url', poster.get('img_url')) - with open (f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as metafile: - json.dump(meta, metafile, indent=4) - metafile.close() - else: - console.print("[bold yellow]Poster could not be retrieved") - elif os.path.exists(poster_img) and meta.get('rehosted_poster') != None: - generic.write(f"TMDB Poster: {meta.get('rehosted_poster')}\n") - if len(meta['image_list']) > 0: - generic.write(f"\nImage Webpage:\n") - for each in meta['image_list']: - generic.write(f"{each['web_url']}\n") - generic.write(f"\nThumbnail Image:\n") - for each in meta['image_list']: - generic.write(f"{each['img_url']}\n") - title = re.sub("[^0-9a-zA-Z\[\]]+", "", meta['title']) - archive = f"{meta['base_dir']}/tmp/{meta['uuid']}/{title}" - torrent_files = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}","*.torrent") - if isinstance(torrent_files, list) and len(torrent_files) > 1: - for each in torrent_files: - if not each.startswith(('BASE', '[RAND')): - os.remove(os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/{each}")) - try: - if os.path.exists(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent"): - base_torrent = Torrent.read(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent") - manual_name = re.sub("[^0-9a-zA-Z\[\]\'\-]+", ".", os.path.basename(meta['path'])) - Torrent.copy(base_torrent).write(f"{meta['base_dir']}/tmp/{meta['uuid']}/{manual_name}.torrent", overwrite=True) - # shutil.copy(os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent"), os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['name'].replace(' ', '.')}.torrent").replace(' ', '.')) - filebrowser = self.config['TRACKERS'].get('MANUAL', {}).get('filebrowser', None) - shutil.make_archive(archive, 'tar', f"{meta['base_dir']}/tmp/{meta['uuid']}") - if filebrowser != None: - url = '/'.join(s.strip('/') for s in (filebrowser, f"/tmp/{meta['uuid']}")) - url = urllib.parse.quote(url, safe="https://") - else: - files = { - "files[]" : (f"{meta['title']}.tar", open(f"{archive}.tar", 'rb')) - } - response = requests.post("https://uguu.se/upload.php", files=files).json() - if meta['debug']: - console.print(f"[cyan]{response}") - url = response['files'][0]['url'] - return url - except Exception: - return False - return - - async def get_imdb_aka(self, imdb_id): - if imdb_id == "0": - return "", None - ia = Cinemagoer() - result = ia.get_movie(imdb_id.replace('tt', '')) - - original_language = result.get('language codes') - if isinstance(original_language, list): - if len(original_language) > 1: - original_language = None - elif len(original_language) == 1: - original_language = original_language[0] - aka = result.get('original title', result.get('localized title', "")).replace(' - IMDb', '').replace('\u00ae', '') - if aka != "": - aka = f" AKA {aka}" - return aka, original_language - - async def get_dvd_size(self, discs): - sizes = [] - dvd_sizes = [] - for each in discs: - sizes.append(each['size']) - grouped_sizes = [list(i) for j, i in itertools.groupby(sorted(sizes))] - for each in grouped_sizes: - if len(each) > 1: - dvd_sizes.append(f"{len(each)}x{each[0]}") - else: - dvd_sizes.append(each[0]) - dvd_sizes.sort() - compact = " ".join(dvd_sizes) - return compact - - - def get_tmdb_imdb_from_mediainfo(self, mediainfo, category, is_disc, tmdbid, imdbid): - if not is_disc: - if mediainfo['media']['track'][0].get('extra'): - extra = mediainfo['media']['track'][0]['extra'] - for each in extra: - if each.lower().startswith('tmdb'): - parser = Args(config=self.config) - category, tmdbid = parser.parse_tmdb_id(id = extra[each], category=category) - if each.lower().startswith('imdb'): - try: - imdbid = str(int(extra[each].replace('tt', ''))).zfill(7) - except Exception: - pass - return category, tmdbid, imdbid - - - def daily_to_tmdb_season_episode(self, tmdbid, date): - show = tmdb.TV(tmdbid) - seasons = show.info().get('seasons') - season = '1' - episode = '1' - date = datetime.fromisoformat(str(date)) - for each in seasons: - air_date = datetime.fromisoformat(each['air_date']) - if air_date <= date: - season = str(each['season_number']) - season_info = tmdb.TV_Seasons(tmdbid, season).info().get('episodes') - for each in season_info: - if str(each['air_date']) == str(date): - episode = str(each['episode_number']) - break - else: - console.print(f"[yellow]Unable to map the date ([bold yellow]{str(date)}[/bold yellow]) to a Season/Episode number") - return season, episode - - - - - async def get_imdb_info(self, imdbID, meta): - imdb_info = {} - if int(str(imdbID).replace('tt', '')) != 0: - ia = Cinemagoer() - info = ia.get_movie(imdbID) - imdb_info['title'] = info.get('title') - imdb_info['year'] = info.get('year') - imdb_info['aka'] = info.get('original title', info.get('localized title', imdb_info['title'])).replace(' - IMDb', '') - imdb_info['type'] = info.get('kind') - imdb_info['imdbID'] = info.get('imdbID') - imdb_info['runtime'] = info.get('runtimes', ['0'])[0] - imdb_info['cover'] = info.get('full-size cover url', '').replace(".jpg", "._V1_FMjpg_UX750_.jpg") - imdb_info['plot'] = info.get('plot', [''])[0] - imdb_info['genres'] = ', '.join(info.get('genres', '')) - imdb_info['original_language'] = info.get('language codes') - if isinstance(imdb_info['original_language'], list): - if len(imdb_info['original_language']) > 1: - imdb_info['original_language'] = None - elif len(imdb_info['original_language']) == 1: - imdb_info['original_language'] = imdb_info['original_language'][0] - if imdb_info['cover'] == '': - imdb_info['cover'] = meta.get('poster', '') - if len(info.get('directors', [])) >= 1: - imdb_info['directors'] = [] - for director in info.get('directors'): - imdb_info['directors'].append(f"nm{director.getID()}") - else: - imdb_info = { - 'title' : meta['title'], - 'year' : meta['year'], - 'aka' : '', - 'type' : None, - 'runtime' : meta.get('runtime', '60'), - 'cover' : meta.get('poster'), - } - if len(meta.get('tmdb_directors', [])) >= 1: - imdb_info['directors'] = meta['tmdb_directors'] - - return imdb_info - - - async def search_imdb(self, filename, search_year): - imdbID = '0' - ia = Cinemagoer() - search = ia.search_movie(filename) - for movie in search: - if filename in movie.get('title', ''): - if movie.get('year') == search_year: - imdbID = str(movie.movieID).replace('tt', '') - return imdbID - - - async def imdb_other_meta(self, meta): - imdb_info = meta['imdb_info'] = await self.get_imdb_info(meta['imdb_id'], meta) - meta['title'] = imdb_info['title'] - meta['year'] = imdb_info['year'] - meta['aka'] = imdb_info['aka'] - meta['poster'] = imdb_info['cover'] - meta['original_language'] = imdb_info['original_language'] - meta['overview'] = imdb_info['plot'] - - difference = SequenceMatcher(None, meta['title'].lower(), meta['aka'][5:].lower()).ratio() - if difference >= 0.9 or meta['aka'][5:].strip() == "" or meta['aka'][5:].strip().lower() in meta['title'].lower(): - meta['aka'] = "" - if f"({meta['year']})" in meta['aka']: - meta['aka'] = meta['aka'].replace(f"({meta['year']})", "").strip() - return meta - - async def search_tvmaze(self, filename, year, imdbID, tvdbID): - tvdbID = int(tvdbID) - tvmazeID = 0 - lookup = False - show = None - if imdbID == None: - imdbID = '0' - if tvdbID == None: - tvdbID = 0 - if int(tvdbID) != 0: - params = { - "thetvdb" : tvdbID - } - url = "https://api.tvmaze.com/lookup/shows" - lookup = True - elif int(imdbID) != 0: - params = { - "imdb" : f"tt{imdbID}" - } - url = "https://api.tvmaze.com/lookup/shows" - lookup = True - else: - params = { - "q" : filename - } - url = f"https://api.tvmaze.com/search/shows" - resp = requests.get(url=url, params=params) - if resp.ok: - resp = resp.json() - if resp == None: - return tvmazeID, imdbID, tvdbID - if lookup == True: - show = resp - else: - if year not in (None, ''): - for each in resp: - premier_date = each['show'].get('premiered', '') - if premier_date != None: - if premier_date.startswith(str(year)): - show = each['show'] - elif len(resp) >= 1: - show = resp[0]['show'] - if show != None: - tvmazeID = show.get('id') - if int(imdbID) == 0: - if show.get('externals', {}).get('imdb', '0') != None: - imdbID = str(show.get('externals', {}).get('imdb', '0')).replace('tt', '') - if int(tvdbID) == 0: - if show.get('externals', {}).get('tvdb', '0') != None: - tvdbID = show.get('externals', {}).get('tvdb', '0') - return tvmazeID, imdbID, tvdbID diff --git a/src/queuemanage.py b/src/queuemanage.py new file mode 100644 index 000000000..33e28e147 --- /dev/null +++ b/src/queuemanage.py @@ -0,0 +1,458 @@ +import os +import json +import glob +import click +import re + +from src.console import console +from rich.markdown import Markdown +from rich.style import Style + + +async def get_log_file(base_dir, queue_name): + """ + Returns the path to the log file for the given base directory and queue name. + """ + safe_queue_name = queue_name.replace(" ", "_") + return os.path.join(base_dir, "tmp", f"{safe_queue_name}_processed_files.log") + + +async def load_processed_files(log_file): + """ + Loads the list of processed files from the log file. + """ + if os.path.exists(log_file): + with open(log_file, 'r', encoding='utf-8') as f: + return set(json.load(f)) + return set() + + +async def gather_files_recursive(path, allowed_extensions=None): + """ + Gather files and first-level subfolders. + Each subfolder is treated as a single unit, without exploring deeper. + Skip folders that don't contain allowed extensions or disc structures (VIDEO_TS/BDMV). + """ + queue = [] + + # Normalize the path to handle Unicode characters properly + try: + if isinstance(path, bytes): + path = path.decode('utf-8', errors='replace') + + # Normalize Unicode characters + import unicodedata + path = unicodedata.normalize('NFC', path) + + # Ensure proper path format + path = os.path.normpath(path) + + except Exception as e: + console.print(f"[yellow]Warning: Path normalization failed for {path}: {e}[/yellow]") + + if os.path.isdir(path): + try: + for entry in os.scandir(path): + try: + # Get the full path and normalize it + entry_path = os.path.normpath(entry.path) + + if entry.is_dir(): + # Check if this directory should be included + if await should_include_directory(entry_path, allowed_extensions): + queue.append(entry_path) + elif entry.is_file() and (allowed_extensions is None or entry.name.lower().endswith(tuple(allowed_extensions))): + queue.append(entry_path) + + except (OSError, UnicodeDecodeError, UnicodeError) as e: + console.print(f"[yellow]Warning: Skipping entry due to encoding issue: {e}[/yellow]") + # Try to get the path in a different way + try: + alt_path = os.path.join(path, entry.name) + if os.path.exists(alt_path): + if os.path.isdir(alt_path) and await should_include_directory(alt_path, allowed_extensions): + queue.append(alt_path) + elif os.path.isfile(alt_path) and (allowed_extensions is None or alt_path.lower().endswith(tuple(allowed_extensions))): + queue.append(alt_path) + except Exception: + continue + + except (OSError, PermissionError) as e: + console.print(f"[red]Error scanning directory {path}: {e}[/red]") + return [] + + elif os.path.isfile(path): + if allowed_extensions is None or path.lower().endswith(tuple(allowed_extensions)): + queue.append(path) + else: + console.print(f"[red]Invalid path: {path}[/red]") + + return queue + + +async def should_include_directory(dir_path, allowed_extensions=None): + """ + Check if a directory should be included in the queue. + Returns True if the directory contains: + - Files with allowed extensions, OR + - A subfolder named 'VIDEO_TS' or 'BDMV' (disc structures) + """ + try: + # Normalize the path + dir_path = os.path.normpath(dir_path) + + # Check for disc structures first (VIDEO_TS or BDMV subfolders) + for entry in os.scandir(dir_path): + if entry.is_dir() and entry.name.upper() in ('VIDEO_TS', 'BDMV'): + return True + + # Check for files with allowed extensions + if allowed_extensions: + for entry in os.scandir(dir_path): + if entry.is_file() and entry.name.lower().endswith(tuple(allowed_extensions)): + return True + else: + # If no allowed_extensions specified, include any directory with files + for entry in os.scandir(dir_path): + if entry.is_file(): + return True + + return False + + except (OSError, PermissionError, UnicodeError) as e: + console.print(f"[yellow]Warning: Could not scan directory {dir_path}: {e}[/yellow]") + return False + + +async def resolve_queue_with_glob_or_split(path, paths, allowed_extensions=None): + """ + Handle glob patterns and split path resolution. + Treat subfolders as single units and filter files by allowed_extensions. + """ + queue = [] + if os.path.exists(os.path.dirname(path)) and len(paths) <= 1: + escaped_path = path.replace('[', '[[]') + queue = [ + file for file in glob.glob(escaped_path) + if os.path.isdir(file) or (os.path.isfile(file) and (allowed_extensions is None or file.lower().endswith(tuple(allowed_extensions)))) + ] + if queue: + await display_queue(queue) + elif os.path.exists(os.path.dirname(path)) and len(paths) > 1: + queue = [ + file for file in paths + if os.path.isdir(file) or (os.path.isfile(file) and (allowed_extensions is None or file.lower().endswith(tuple(allowed_extensions)))) + ] + await display_queue(queue) + elif not os.path.exists(os.path.dirname(path)): + queue = [ + file for file in resolve_split_path(path) # noqa F821 + if os.path.isdir(file) or (os.path.isfile(file) and (allowed_extensions is None or file.lower().endswith(tuple(allowed_extensions)))) + ] + await display_queue(queue) + return queue + + +async def extract_safe_file_locations(log_file): + """ + Parse the log file to extract file locations under the 'safe' header. + + :param log_file: Path to the log file to parse. + :return: List of file paths from the 'safe' section. + """ + safe_section = False + safe_file_locations = [] + + with open(log_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + + # Detect the start and end of 'safe' sections + if line.lower() == "safe": + safe_section = True + continue + elif line.lower() in {"danger", "risky"}: + safe_section = False + + # Extract 'File Location' if in a 'safe' section + if safe_section and line.startswith("File Location:"): + match = re.search(r"File Location:\s*(.+)", line) + if match: + safe_file_locations.append(match.group(1).strip()) + + return safe_file_locations + + +async def display_queue(queue, base_dir, queue_name, save_to_log=True): + """Displays the queued files in markdown format and optionally saves them to a log file in the tmp directory.""" + md_text = "\n - ".join(queue) + console.print("\n[bold green]Queuing these files:[/bold green]", end='') + console.print(Markdown(f"- {md_text.rstrip()}\n\n", style=Style(color='cyan'))) + console.print("\n\n") + + if save_to_log: + tmp_dir = os.path.join(base_dir, "tmp") + os.makedirs(tmp_dir, exist_ok=True) + log_file = os.path.join(tmp_dir, f"{queue_name}_queue.log") + + try: + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + console.print(f"[bold green]Queue successfully saved to log file: {log_file}") + except Exception as e: + console.print(f"[bold red]Failed to save queue to log file: {e}") + + +async def handle_queue(path, meta, paths, base_dir): + allowed_extensions = ['.mkv', '.mp4', '.ts'] + queue = [] + + log_file = os.path.join(base_dir, "tmp", f"{meta['queue']}_queue.log") + allowed_extensions = ['.mkv', '.mp4', '.ts'] + + if path.endswith('.txt') and meta.get('unit3d'): + console.print(f"[bold yellow]Detected a text file for queue input: {path}[/bold yellow]") + if os.path.exists(path): + safe_file_locations = await extract_safe_file_locations(path) + if safe_file_locations: + console.print(f"[cyan]Extracted {len(safe_file_locations)} safe file locations from the text file.[/cyan]") + queue = safe_file_locations + meta['queue'] = "unit3d" + + # Save the queue to the log file + try: + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + console.print(f"[bold green]Queue log file saved successfully: {log_file}[/bold green]") + except IOError as e: + console.print(f"[bold red]Failed to save the queue log file: {e}[/bold red]") + exit(1) + else: + console.print("[bold red]No safe file locations found in the text file. Exiting.[/bold red]") + exit(1) + else: + console.print(f"[bold red]Text file not found: {path}. Exiting.[/bold red]") + exit(1) + + elif path.endswith('.log') and meta['debug']: + console.print(f"[bold yellow]Processing debugging queue:[/bold yellow] [bold green{path}[/bold green]") + if os.path.exists(path): + log_file = path + with open(path, 'r') as f: + queue = json.load(f) + meta['queue'] = "debugging" + + else: + console.print(f"[bold red]Log file not found: {path}. Exiting.[/bold red]") + exit(1) + + elif meta.get('queue'): + if os.path.exists(log_file): + with open(log_file, 'r', encoding='utf-8') as f: + existing_queue = json.load(f) + + if os.path.exists(path): + current_files = await gather_files_recursive(path, allowed_extensions=allowed_extensions) + else: + current_files = await resolve_queue_with_glob_or_split(path, paths, allowed_extensions=allowed_extensions) + + existing_set = set(existing_queue) + current_set = set(current_files) + new_files = current_set - existing_set + removed_files = existing_set - current_set + log_file_proccess = await get_log_file(base_dir, meta['queue']) + processed_files = await load_processed_files(log_file_proccess) + queued = [file for file in existing_queue if file not in processed_files] + + console.print(f"[bold yellow]Found an existing queue log file:[/bold yellow] [green]{log_file}[/green]") + console.print(f"[cyan]The queue log contains {len(existing_queue)} total items and {len(queued)} unprocessed items.[/cyan]") + + if new_files or removed_files: + console.print("[bold yellow]Queue changes detected:[/bold yellow]") + if new_files: + console.print(f"[green]New files found ({len(new_files)}):[/green]") + for file in sorted(new_files): + console.print(f" + {file}") + if removed_files: + console.print(f"[red]Removed files ({len(removed_files)}):[/red]") + for file in sorted(removed_files): + console.print(f" - {file}") + + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print("[yellow]Do you want to update the queue log, edit, discard, or keep the existing queue?[/yellow]") + edit_choice = input("Enter 'u' to update, 'a' to add specific new files, 'e' to edit, 'd' to discard, or press Enter to keep it as is: ").strip().lower() + + if edit_choice == 'u': + queue = current_files + console.print(f"[bold green]Queue updated with current files ({len(queue)} items).") + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + console.print(f"[bold green]Queue log file updated: {log_file}[/bold green]") + elif edit_choice == 'a': + console.print("[yellow]Select which new files to add (comma-separated numbers):[/yellow]") + for idx, file in enumerate(sorted(new_files), 1): + console.print(f" {idx}. {file}") + selected = input("Enter numbers (e.g., 1,3,5): ").strip() + try: + indices = [int(x) for x in selected.split(',') if x.strip().isdigit()] + selected_files = [file for i, file in enumerate(sorted(new_files), 1) if i in indices] + queue = list(existing_queue) + selected_files + console.print(f"[bold green]Queue updated with selected new files ({len(queue)} items).") + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + console.print(f"[bold green]Queue log file updated: {log_file}[/bold green]") + except Exception as e: + console.print(f"[bold red]Failed to update queue with selected files: {e}. Using the existing queue.") + queue = existing_queue + elif edit_choice == 'e': + edited_content = click.edit(json.dumps(current_files, indent=4)) + if edited_content: + try: + queue = json.loads(edited_content.strip()) + console.print("[bold green]Successfully updated the queue from the editor.") + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + except json.JSONDecodeError as e: + console.print(f"[bold red]Failed to parse the edited content: {e}. Using the current files.") + queue = current_files + else: + console.print("[bold red]No changes were made. Using the current files.") + queue = current_files + elif edit_choice == 'd': + console.print("[bold yellow]Discarding the existing queue log. Creating a new queue.") + queue = current_files + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + console.print(f"[bold green]New queue log file created: {log_file}[/bold green]") + else: + console.print("[bold green]Keeping the existing queue as is.") + queue = existing_queue + else: + # In unattended mode, just use the existing queue + queue = existing_queue + console.print("[bold yellow]New or removed files detected, but unattended mode is active. Using existing queue.") + else: + # No changes detected + console.print("[green]No changes detected in the queue.[/green]") + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print("[yellow]Do you want to edit, discard, or keep the existing queue?[/yellow]") + edit_choice = input("Enter 'e' to edit, 'd' to discard, or press Enter to keep it as is: ").strip().lower() + + if edit_choice == 'e': + edited_content = click.edit(json.dumps(existing_queue, indent=4)) + if edited_content: + try: + queue = json.loads(edited_content.strip()) + console.print("[bold green]Successfully updated the queue from the editor.") + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + except json.JSONDecodeError as e: + console.print(f"[bold red]Failed to parse the edited content: {e}. Using the original queue.") + queue = existing_queue + else: + console.print("[bold red]No changes were made. Using the original queue.") + queue = existing_queue + elif edit_choice == 'd': + console.print("[bold yellow]Discarding the existing queue log. Creating a new queue.") + queue = current_files + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + console.print(f"[bold green]New queue log file created: {log_file}[/bold green]") + else: + console.print("[bold green]Keeping the existing queue as is.") + queue = existing_queue + else: + console.print("[bold green]Keeping the existing queue as is.") + queue = existing_queue + else: + if os.path.exists(path): + queue = await gather_files_recursive(path, allowed_extensions=allowed_extensions) + else: + queue = await resolve_queue_with_glob_or_split(path, paths, allowed_extensions=allowed_extensions) + + console.print(f"[cyan]A new queue log file will be created:[/cyan] [green]{log_file}[/green]") + console.print(f"[cyan]The new queue will contain {len(queue)} items.[/cyan]") + console.print("[cyan]Do you want to edit the initial queue before saving?[/cyan]") + edit_choice = input("Enter 'e' to edit, or press Enter to save as is: ").strip().lower() + + if edit_choice == 'e': + edited_content = click.edit(json.dumps(queue, indent=4)) + if edited_content: + try: + queue = json.loads(edited_content.strip()) + console.print("[bold green]Successfully updated the queue from the editor.") + except json.JSONDecodeError as e: + console.print(f"[bold red]Failed to parse the edited content: {e}. Using the original queue.") + else: + console.print("[bold red]No changes were made. Using the original queue.") + + # Save the queue to the log file + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(queue, f, indent=4) + console.print(f"[bold green]Queue log file created: {log_file}[/bold green]") + + elif os.path.exists(path): + queue = [path] + + else: + # Search glob if dirname exists + if os.path.exists(os.path.dirname(path)) and len(paths) <= 1: + escaped_path = path.replace('[', '[[]') + globs = glob.glob(escaped_path) + queue = globs + if len(queue) != 0: + md_text = "\n - ".join(queue) + console.print("\n[bold green]Queuing these files:[/bold green]", end='') + console.print(Markdown(f"- {md_text.rstrip()}\n\n", style=Style(color='cyan'))) + console.print("\n\n") + else: + console.print(f"[red]Path: [bold red]{path}[/bold red] does not exist") + + elif os.path.exists(os.path.dirname(path)) and len(paths) != 1: + queue = paths + md_text = "\n - ".join(queue) + console.print("\n[bold green]Queuing these files:[/bold green]", end='') + console.print(Markdown(f"- {md_text.rstrip()}\n\n", style=Style(color='cyan'))) + console.print("\n\n") + elif not os.path.exists(os.path.dirname(path)): + split_path = path.split() + p1 = split_path[0] + for i, each in enumerate(split_path): + try: + if os.path.exists(p1) and not os.path.exists(f"{p1} {split_path[i + 1]}"): + queue.append(p1) + p1 = split_path[i + 1] + else: + p1 += f" {split_path[i + 1]}" + except IndexError: + if os.path.exists(p1): + queue.append(p1) + else: + console.print(f"[red]Path: [bold red]{p1}[/bold red] does not exist") + if len(queue) >= 1: + md_text = "\n - ".join(queue) + console.print("\n[bold green]Queuing these files:[/bold green]", end='') + console.print(Markdown(f"- {md_text.rstrip()}\n\n", style=Style(color='cyan'))) + console.print("\n\n") + + else: + # Add Search Here + console.print("[red]There was an issue with your input. If you think this was not an issue, please make a report that includes the full command used.") + exit() + + if not queue: + console.print(f"[red]No valid files or directories found for path: {path}") + exit(1) + + if meta.get('queue'): + queue_name = meta['queue'] + log_file = await get_log_file(base_dir, meta['queue']) + processed_files = await load_processed_files(log_file) + queue = [file for file in queue if file not in processed_files] + if not queue: + console.print(f"[bold yellow]All files in the {meta['queue']} queue have already been processed.") + exit(0) + if meta['debug']: + await display_queue(queue, base_dir, queue_name, save_to_log=False) + + return queue, log_file diff --git a/src/radarr.py b/src/radarr.py new file mode 100644 index 000000000..2119a5106 --- /dev/null +++ b/src/radarr.py @@ -0,0 +1,116 @@ +import httpx +from data.config import config +from src.console import console + + +async def get_radarr_data(tmdb_id=None, filename=None, debug=False): + if not any(key.startswith('radarr_api_key') for key in config['DEFAULT']): + console.print("[red]No Radarr API keys are configured.[/red]") + return None + + # Try each Radarr instance until we get valid data + instance_index = 0 + max_instances = 4 # Limit instances to prevent infinite loops + + while instance_index < max_instances: + # Determine the suffix for this instance + suffix = "" if instance_index == 0 else f"_{instance_index}" + api_key_name = f"radarr_api_key{suffix}" + url_name = f"radarr_url{suffix}" + + # Check if this instance exists in config + if api_key_name not in config['DEFAULT'] or not config['DEFAULT'][api_key_name]: + # No more instances to try + break + + # Get instance-specific configuration + api_key = config['DEFAULT'][api_key_name].strip() + base_url = config['DEFAULT'][url_name].strip() + + if debug: + console.print(f"[blue]Trying Radarr instance {instance_index if instance_index > 0 else 'default'}[/blue]") + + # Build the appropriate URL + if tmdb_id: + url = f"{base_url}/api/v3/movie?tmdbId={tmdb_id}&excludeLocalCovers=true" + elif filename: + url = f"{base_url}/api/v3/movie/lookup?term={filename}" + else: + instance_index += 1 + continue + + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + if debug: + console.print(f"[green]TMDB ID {tmdb_id}[/green]") + console.print(f"[blue]Radarr URL:[/blue] {url}") + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=10.0) + + if response.status_code == 200: + data = response.json() + + if debug: + console.print(f"[blue]Radarr Response Status:[/blue] {response.status_code}") + console.print(f"[blue]Radarr Response Data:[/blue] {data}") + + # Check if we got valid data by trying to extract movie info + movie_data = await extract_movie_data(data, filename) + + if movie_data and (movie_data.get("imdb_id") or movie_data.get("tmdb_id")): + console.print(f"[green]Found valid movie data from Radarr instance {instance_index if instance_index > 0 else 'default'}[/green]") + return movie_data + else: + console.print(f"[yellow]Failed to fetch from Radarr instance {instance_index if instance_index > 0 else 'default'}: {response.status_code} - {response.text}[/yellow]") + + except httpx.RequestError as e: + console.print(f"[red]Error fetching from Radarr instance {instance_index if instance_index > 0 else 'default'}: {e}[/red]") + except httpx.TimeoutException: + console.print(f"[red]Timeout when fetching from Radarr instance {instance_index if instance_index > 0 else 'default'}[/red]") + except Exception as e: + console.print(f"[red]Unexpected error with Radarr instance {instance_index if instance_index > 0 else 'default'}: {e}[/red]") + + # Move to the next instance + instance_index += 1 + + # If we got here, no instances provided valid data + console.print("[yellow]No Radarr instance returned valid movie data.[/yellow]") + return None + + +async def extract_movie_data(radarr_data, filename=None): + if not radarr_data or not isinstance(radarr_data, list) or len(radarr_data) == 0: + return { + "imdb_id": None, + "tmdb_id": None, + "year": None, + "genres": [], + "release_group": None + } + + if filename: + for item in radarr_data: + if item.get("movieFile") and item["movieFile"].get("originalFilePath") == filename: + movie = item + break + else: + return None + else: + movie = radarr_data[0] + + release_group = None + if movie.get("movieFile") and movie["movieFile"].get("releaseGroup"): + release_group = movie["movieFile"]["releaseGroup"] + + return { + "imdb_id": int(movie.get("imdbId", "tt0").replace("tt", "")) if movie.get("imdbId") else None, + "tmdb_id": movie.get("tmdbId", None), + "year": movie.get("year", None), + "genres": movie.get("genres", []), + "release_group": release_group if release_group else None + } diff --git a/src/region.py b/src/region.py new file mode 100644 index 000000000..83c503282 --- /dev/null +++ b/src/region.py @@ -0,0 +1,152 @@ +import re +from guessit import guessit + + +async def get_region(bdinfo, region=None): + label = bdinfo.get('label', bdinfo.get('title', bdinfo.get('path', ''))).replace('.', ' ') + if region is not None: + region = region.upper() + else: + regions = { + 'AFG': 'AFG', 'AIA': 'AIA', 'ALA': 'ALA', 'ALG': 'ALG', 'AND': 'AND', 'ANG': 'ANG', 'ARG': 'ARG', + 'ARM': 'ARM', 'ARU': 'ARU', 'ASA': 'ASA', 'ATA': 'ATA', 'ATF': 'ATF', 'ATG': 'ATG', 'AUS': 'AUS', + 'AUT': 'AUT', 'AZE': 'AZE', 'BAH': 'BAH', 'BAN': 'BAN', 'BDI': 'BDI', 'BEL': 'BEL', 'BEN': 'BEN', + 'BER': 'BER', 'BES': 'BES', 'BFA': 'BFA', 'BHR': 'BHR', 'BHU': 'BHU', 'BIH': 'BIH', 'BLM': 'BLM', + 'BLR': 'BLR', 'BLZ': 'BLZ', 'BOL': 'BOL', 'BOT': 'BOT', 'BRA': 'BRA', 'BRB': 'BRB', 'BRU': 'BRU', + 'BVT': 'BVT', 'CAM': 'CAM', 'CAN': 'CAN', 'CAY': 'CAY', 'CCK': 'CCK', 'CEE': 'CEE', 'CGO': 'CGO', + 'CHA': 'CHA', 'CHI': 'CHI', 'CHN': 'CHN', 'CIV': 'CIV', 'CMR': 'CMR', 'COD': 'COD', 'COK': 'COK', + 'COL': 'COL', 'COM': 'COM', 'CPV': 'CPV', 'CRC': 'CRC', 'CRO': 'CRO', 'CTA': 'CTA', 'CUB': 'CUB', + 'CUW': 'CUW', 'CXR': 'CXR', 'CYP': 'CYP', 'DJI': 'DJI', 'DMA': 'DMA', 'DOM': 'DOM', 'ECU': 'ECU', + 'EGY': 'EGY', 'ENG': 'ENG', 'EQG': 'EQG', 'ERI': 'ERI', 'ESH': 'ESH', 'ESP': 'ESP', 'ETH': 'ETH', + 'FIJ': 'FIJ', 'FLK': 'FLK', 'FRA': 'FRA', 'FRO': 'FRO', 'FSM': 'FSM', 'GAB': 'GAB', 'GAM': 'GAM', + 'GBR': 'GBR', 'GEO': 'GEO', 'GER': 'GER', 'GGY': 'GGY', 'GHA': 'GHA', 'GIB': 'GIB', 'GLP': 'GLP', + 'GNB': 'GNB', 'GRE': 'GRE', 'GRL': 'GRL', 'GRN': 'GRN', 'GUA': 'GUA', 'GUF': 'GUF', 'GUI': 'GUI', + 'GUM': 'GUM', 'GUY': 'GUY', 'HAI': 'HAI', 'HKG': 'HKG', 'HMD': 'HMD', 'HON': 'HON', 'HUN': 'HUN', + 'IDN': 'IDN', 'IMN': 'IMN', 'IND': 'IND', 'IOT': 'IOT', 'IRL': 'IRL', 'IRN': 'IRN', 'IRQ': 'IRQ', + 'ISL': 'ISL', 'ISR': 'ISR', 'ITA': 'ITA', 'JAM': 'JAM', 'JEY': 'JEY', 'JOR': 'JOR', 'JPN': 'JPN', + 'KAZ': 'KAZ', 'KEN': 'KEN', 'KGZ': 'KGZ', 'KIR': 'KIR', 'KNA': 'KNA', 'KOR': 'KOR', 'KSA': 'KSA', + 'KUW': 'KUW', 'KVX': 'KVX', 'LAO': 'LAO', 'LBN': 'LBN', 'LBR': 'LBR', 'LBY': 'LBY', 'LCA': 'LCA', + 'LES': 'LES', 'LIE': 'LIE', 'LKA': 'LKA', 'LUX': 'LUX', 'MAC': 'MAC', 'MAD': 'MAD', 'MAF': 'MAF', + 'MAR': 'MAR', 'MAS': 'MAS', 'MDA': 'MDA', 'MDV': 'MDV', 'MEX': 'MEX', 'MHL': 'MHL', 'MKD': 'MKD', + 'MLI': 'MLI', 'MLT': 'MLT', 'MNG': 'MNG', 'MNP': 'MNP', 'MON': 'MON', 'MOZ': 'MOZ', 'MRI': 'MRI', + 'MSR': 'MSR', 'MTN': 'MTN', 'MTQ': 'MTQ', 'MWI': 'MWI', 'MYA': 'MYA', 'MYT': 'MYT', 'NAM': 'NAM', + 'NCA': 'NCA', 'NCL': 'NCL', 'NEP': 'NEP', 'NFK': 'NFK', 'NIG': 'NIG', 'NIR': 'NIR', 'NIU': 'NIU', + 'NLD': 'NLD', 'NOR': 'NOR', 'NRU': 'NRU', 'NZL': 'NZL', 'OMA': 'OMA', 'PAK': 'PAK', 'PAN': 'PAN', + 'PAR': 'PAR', 'PCN': 'PCN', 'PER': 'PER', 'PHI': 'PHI', 'PLE': 'PLE', 'PLW': 'PLW', 'PNG': 'PNG', + 'POL': 'POL', 'POR': 'POR', 'PRK': 'PRK', 'PUR': 'PUR', 'QAT': 'QAT', 'REU': 'REU', 'ROU': 'ROU', + 'RSA': 'RSA', 'RUS': 'RUS', 'RWA': 'RWA', 'SAM': 'SAM', 'SCO': 'SCO', 'SDN': 'SDN', 'SEN': 'SEN', + 'SEY': 'SEY', 'SGS': 'SGS', 'SHN': 'SHN', 'SIN': 'SIN', 'SJM': 'SJM', 'SLE': 'SLE', 'SLV': 'SLV', + 'SMR': 'SMR', 'SOL': 'SOL', 'SOM': 'SOM', 'SPM': 'SPM', 'SRB': 'SRB', 'SSD': 'SSD', 'STP': 'STP', + 'SUI': 'SUI', 'SUR': 'SUR', 'SWZ': 'SWZ', 'SXM': 'SXM', 'SYR': 'SYR', 'TAH': 'TAH', 'TAN': 'TAN', + 'TCA': 'TCA', 'TGA': 'TGA', 'THA': 'THA', 'TJK': 'TJK', 'TKL': 'TKL', 'TKM': 'TKM', 'TLS': 'TLS', + 'TOG': 'TOG', 'TRI': 'TRI', 'TUN': 'TUN', 'TUR': 'TUR', 'TUV': 'TUV', 'TWN': 'TWN', 'UAE': 'UAE', + 'UGA': 'UGA', 'UKR': 'UKR', 'UMI': 'UMI', 'URU': 'URU', 'USA': 'USA', 'UZB': 'UZB', 'VAN': 'VAN', + 'VAT': 'VAT', 'VEN': 'VEN', 'VGB': 'VGB', 'VIE': 'VIE', 'VIN': 'VIN', 'VIR': 'VIR', 'WAL': 'WAL', + 'WLF': 'WLF', 'YEM': 'YEM', 'ZAM': 'ZAM', 'ZIM': 'ZIM', "EUR": "EUR" + } + for key, value in regions.items(): + if f" {key} " in label: + region = value + + if region is None: + region = "" + return region + + +async def get_distributor(distributor_in): + distributor_list = [ + '01 DISTRIBUTION', '100 DESTINATIONS TRAVEL FILM', '101 FILMS', '1FILMS', '2 ENTERTAIN VIDEO', '20TH CENTURY FOX', '2L', '3D CONTENT HUB', '3D MEDIA', '3L FILM', '4DIGITAL', '4DVD', '4K ULTRA HD MOVIES', '4K UHD', '8-FILMS', '84 ENTERTAINMENT', '88 FILMS', '@ANIME', 'ANIME', 'A CONTRACORRIENTE', 'A CONTRACORRIENTE FILMS', 'A&E HOME VIDEO', 'A&E', 'A&M RECORDS', 'A+E NETWORKS', 'A+R', 'A-FILM', 'AAA', 'AB VIDÉO', 'AB VIDEO', 'ABC - (AUSTRALIAN BROADCASTING CORPORATION)', 'ABC', 'ABKCO', 'ABSOLUT MEDIEN', 'ABSOLUTE', 'ACCENT FILM ENTERTAINMENT', 'ACCENTUS', 'ACORN MEDIA', 'AD VITAM', 'ADA', 'ADITYA VIDEOS', 'ADSO FILMS', 'AFM RECORDS', 'AGFA', 'AIX RECORDS', + 'ALAMODE FILM', 'ALBA RECORDS', 'ALBANY RECORDS', 'ALBATROS', 'ALCHEMY', 'ALIVE', 'ALL ANIME', 'ALL INTERACTIVE ENTERTAINMENT', 'ALLEGRO', 'ALLIANCE', 'ALPHA MUSIC', 'ALTERDYSTRYBUCJA', 'ALTERED INNOCENCE', 'ALTITUDE FILM DISTRIBUTION', 'ALUCARD RECORDS', 'AMAZING D.C.', 'AMAZING DC', 'AMMO CONTENT', 'AMUSE SOFT ENTERTAINMENT', 'ANCONNECT', 'ANEC', 'ANIMATSU', 'ANIME HOUSE', 'ANIME LTD', 'ANIME WORKS', 'ANIMEIGO', 'ANIPLEX', 'ANOLIS ENTERTAINMENT', 'ANOTHER WORLD ENTERTAINMENT', 'AP INTERNATIONAL', 'APPLE', 'ARA MEDIA', 'ARBELOS', 'ARC ENTERTAINMENT', 'ARP SÉLECTION', 'ARP SELECTION', 'ARROW', 'ART SERVICE', 'ART VISION', 'ARTE ÉDITIONS', 'ARTE EDITIONS', 'ARTE VIDÉO', + 'ARTE VIDEO', 'ARTHAUS MUSIK', 'ARTIFICIAL EYE', 'ARTSPLOITATION FILMS', 'ARTUS FILMS', 'ASCOT ELITE HOME ENTERTAINMENT', 'ASIA VIDEO', 'ASMIK ACE', 'ASTRO RECORDS & FILMWORKS', 'ASYLUM', 'ATLANTIC FILM', 'ATLANTIC RECORDS', 'ATLAS FILM', 'AUDIO VISUAL ENTERTAINMENT', 'AURO-3D CREATIVE LABEL', 'AURUM', 'AV VISIONEN', 'AV-JET', 'AVALON', 'AVENTI', 'AVEX TRAX', 'AXIOM', 'AXIS RECORDS', 'AYNGARAN', 'BAC FILMS', 'BACH FILMS', 'BANDAI VISUAL', 'BARCLAY', 'BBC', 'BRITISH BROADCASTING CORPORATION', 'BBI FILMS', 'BBI', 'BCI HOME ENTERTAINMENT', 'BEGGARS BANQUET', 'BEL AIR CLASSIQUES', 'BELGA FILMS', 'BELVEDERE', 'BENELUX FILM DISTRIBUTORS', 'BENNETT-WATT MEDIA', 'BERLIN CLASSICS', 'BERLINER PHILHARMONIKER RECORDINGS', 'BEST ENTERTAINMENT', 'BEYOND HOME ENTERTAINMENT', 'BFI VIDEO', 'BFI', 'BRITISH FILM INSTITUTE', 'BFS ENTERTAINMENT', 'BFS', 'BHAVANI', 'BIBER RECORDS', 'BIG HOME VIDEO', 'BILDSTÖRUNG', + 'BILDSTORUNG', 'BILL ZEBUB', 'BIRNENBLATT', 'BIT WEL', 'BLACK BOX', 'BLACK HILL PICTURES', 'BLACK HILL', 'BLACK HOLE RECORDINGS', 'BLACK HOLE', 'BLAQOUT', 'BLAUFIELD MUSIC', 'BLAUFIELD', 'BLOCKBUSTER ENTERTAINMENT', 'BLOCKBUSTER', 'BLU PHASE MEDIA', 'BLU-RAY ONLY', 'BLU-RAY', 'BLURAY ONLY', 'BLURAY', 'BLUE GENTIAN RECORDS', 'BLUE KINO', 'BLUE UNDERGROUND', 'BMG/ARISTA', 'BMG', 'BMGARISTA', 'BMG ARISTA', 'ARISTA', 'ARISTA/BMG', 'ARISTABMG', 'ARISTA BMG', 'BONTON FILM', 'BONTON', 'BOOMERANG PICTURES', 'BOOMERANG', 'BQHL ÉDITIONS', 'BQHL EDITIONS', 'BQHL', 'BREAKING GLASS', 'BRIDGESTONE', 'BRINK', 'BROAD GREEN PICTURES', 'BROAD GREEN', 'BUSCH MEDIA GROUP', 'BUSCH', 'C MAJOR', 'C.B.S.', 'CAICHANG', 'CALIFÓRNIA FILMES', 'CALIFORNIA FILMES', 'CALIFORNIA', 'CAMEO', 'CAMERA OBSCURA', 'CAMERATA', 'CAMP MOTION PICTURES', 'CAMP MOTION', 'CAPELIGHT PICTURES', 'CAPELIGHT', 'CAPITOL', 'CAPITOL RECORDS', 'CAPRICCI', 'CARGO RECORDS', 'CARLOTTA FILMS', 'CARLOTTA', 'CARLOTA', 'CARMEN FILM', 'CASCADE', 'CATCHPLAY', 'CAULDRON FILMS', 'CAULDRON', 'CBS TELEVISION STUDIOS', 'CBS', 'CCTV', 'CCV ENTERTAINMENT', 'CCV', 'CD BABY', 'CD LAND', 'CECCHI GORI', 'CENTURY MEDIA', 'CHUAN XUN SHI DAI MULTIMEDIA', 'CINE-ASIA', 'CINÉART', 'CINEART', 'CINEDIGM', 'CINEFIL IMAGICA', 'CINEMA EPOCH', 'CINEMA GUILD', 'CINEMA LIBRE STUDIOS', 'CINEMA MONDO', 'CINEMATIC VISION', 'CINEPLOIT RECORDS', 'CINESTRANGE EXTREME', 'CITEL VIDEO', 'CITEL', 'CJ ENTERTAINMENT', 'CJ', 'CLASSIC MEDIA', 'CLASSICFLIX', 'CLASSICLINE', 'CLAUDIO RECORDS', 'CLEAR VISION', 'CLEOPATRA', 'CLOSE UP', 'CMS MEDIA LIMITED', 'CMV LASERVISION', 'CN ENTERTAINMENT', 'CODE RED', 'COHEN MEDIA GROUP', 'COHEN', 'COIN DE MIRE CINÉMA', 'COIN DE MIRE CINEMA', 'COLOSSEO FILM', 'COLUMBIA', 'COLUMBIA PICTURES', 'COLUMBIA/TRI-STAR', 'TRI-STAR', 'COMMERCIAL MARKETING', 'CONCORD MUSIC GROUP', 'CONCORDE VIDEO', 'CONDOR', 'CONSTANTIN FILM', 'CONSTANTIN', 'CONSTANTINO FILMES', 'CONSTANTINO', 'CONSTRUCTIVE MEDIA SERVICE', 'CONSTRUCTIVE', 'CONTENT ZONE', 'CONTENTS GATE', 'COQUEIRO VERDE', 'CORNERSTONE MEDIA', 'CORNERSTONE', 'CP DIGITAL', 'CREST MOVIES', 'CRITERION', 'CRITERION COLLECTION', 'CC', 'CRYSTAL CLASSICS', 'CULT EPICS', 'CULT FILMS', 'CULT VIDEO', 'CURZON FILM WORLD', 'D FILMS', "D'AILLY COMPANY", 'DAILLY COMPANY', 'D AILLY COMPANY', "D'AILLY", 'DAILLY', 'D AILLY', 'DA CAPO', 'DA MUSIC', "DALL'ANGELO PICTURES", 'DALLANGELO PICTURES', "DALL'ANGELO", 'DALL ANGELO PICTURES', 'DALL ANGELO', 'DAREDO', 'DARK FORCE ENTERTAINMENT', 'DARK FORCE', 'DARK SIDE RELEASING', 'DARK SIDE', 'DAZZLER MEDIA', 'DAZZLER', 'DCM PICTURES', 'DCM', 'DEAPLANETA', 'DECCA', 'DEEPJOY', 'DEFIANT SCREEN ENTERTAINMENT', 'DEFIANT SCREEN', 'DEFIANT', 'DELOS', 'DELPHIAN RECORDS', 'DELPHIAN', 'DELTA MUSIC & ENTERTAINMENT', 'DELTA MUSIC AND ENTERTAINMENT', 'DELTA MUSIC ENTERTAINMENT', 'DELTA MUSIC', 'DELTAMAC CO. LTD.', 'DELTAMAC CO LTD', 'DELTAMAC CO', 'DELTAMAC', 'DEMAND MEDIA', 'DEMAND', 'DEP', 'DEUTSCHE GRAMMOPHON', 'DFW', 'DGM', 'DIAPHANA', 'DIGIDREAMS STUDIOS', 'DIGIDREAMS', 'DIGITAL ENVIRONMENTS', 'DIGITAL', 'DISCOTEK MEDIA', 'DISCOVERY CHANNEL', 'DISCOVERY', 'DISK KINO', 'DISNEY / BUENA VISTA', 'DISNEY', 'BUENA VISTA', 'DISNEY BUENA VISTA', 'DISTRIBUTION SELECT', 'DIVISA', 'DNC ENTERTAINMENT', 'DNC', 'DOGWOOF', 'DOLMEN HOME VIDEO', 'DOLMEN', 'DONAU FILM', 'DONAU', 'DORADO FILMS', 'DORADO', 'DRAFTHOUSE FILMS', 'DRAFTHOUSE', 'DRAGON FILM ENTERTAINMENT', 'DRAGON ENTERTAINMENT', 'DRAGON FILM', 'DRAGON', 'DREAMWORKS', 'DRIVE ON RECORDS', 'DRIVE ON', 'DRIVE-ON', 'DRIVEON', 'DS MEDIA', 'DTP ENTERTAINMENT AG', 'DTP ENTERTAINMENT', 'DTP AG', 'DTP', 'DTS ENTERTAINMENT', 'DTS', 'DUKE MARKETING', 'DUKE VIDEO DISTRIBUTION', 'DUKE', 'DUTCH FILMWORKS', 'DUTCH', 'DVD INTERNATIONAL', 'DVD', 'DYBEX', 'DYNAMIC', 'DYNIT', 'E1 ENTERTAINMENT', 'E1', 'EAGLE ENTERTAINMENT', 'EAGLE HOME ENTERTAINMENT PVT.LTD.', 'EAGLE HOME ENTERTAINMENT PVTLTD', 'EAGLE HOME ENTERTAINMENT PVT LTD', 'EAGLE HOME ENTERTAINMENT', 'EAGLE PICTURES', 'EAGLE ROCK ENTERTAINMENT', 'EAGLE ROCK', 'EAGLE VISION MEDIA', 'EAGLE VISION', 'EARMUSIC', 'EARTH ENTERTAINMENT', 'EARTH', 'ECHO BRIDGE ENTERTAINMENT', 'ECHO BRIDGE', 'EDEL GERMANY GMBH', 'EDEL GERMANY', 'EDEL RECORDS', 'EDITION TONFILM', 'EDITIONS MONTPARNASSE', 'EDKO FILMS LTD.', 'EDKO FILMS LTD', 'EDKO FILMS', + 'EDKO', "EIN'S M&M CO", 'EINS M&M CO', "EIN'S M&M", 'EINS M&M', 'ELEA-MEDIA', 'ELEA MEDIA', 'ELEA', 'ELECTRIC PICTURE', 'ELECTRIC', 'ELEPHANT FILMS', 'ELEPHANT', 'ELEVATION', 'EMI', 'EMON', 'EMS', 'EMYLIA', 'ENE MEDIA', 'ENE', 'ENTERTAINMENT IN VIDEO', 'ENTERTAINMENT IN', 'ENTERTAINMENT ONE', 'ENTERTAINMENT ONE FILMS CANADA INC.', 'ENTERTAINMENT ONE FILMS CANADA INC', 'ENTERTAINMENT ONE FILMS CANADA', 'ENTERTAINMENT ONE CANADA INC', 'ENTERTAINMENT ONE CANADA', 'ENTERTAINMENTONE', 'EONE', 'EOS', 'EPIC PICTURES', 'EPIC', 'EPIC RECORDS', 'ERATO', 'EROS', 'ESC EDITIONS', 'ESCAPI MEDIA BV', 'ESOTERIC RECORDINGS', 'ESPN FILMS', 'EUREKA ENTERTAINMENT', 'EUREKA', 'EURO PICTURES', 'EURO VIDEO', 'EUROARTS', 'EUROPA FILMES', 'EUROPA', 'EUROPACORP', 'EUROZOOM', 'EXCEL', 'EXPLOSIVE MEDIA', 'EXPLOSIVE', 'EXTRALUCID FILMS', 'EXTRALUCID', 'EYE SEE MOVIES', 'EYE SEE', 'EYK MEDIA', 'EYK', 'FABULOUS FILMS', 'FABULOUS', 'FACTORIS FILMS', 'FACTORIS', 'FARAO RECORDS', 'FARBFILM HOME ENTERTAINMENT', 'FARBFILM ENTERTAINMENT', 'FARBFILM HOME', 'FARBFILM', 'FEELGOOD ENTERTAINMENT', 'FEELGOOD', 'FERNSEHJUWELEN', 'FILM CHEST', 'FILM MEDIA', 'FILM MOVEMENT', 'FILM4', 'FILMART', 'FILMAURO', 'FILMAX', 'FILMCONFECT HOME ENTERTAINMENT', 'FILMCONFECT ENTERTAINMENT', 'FILMCONFECT HOME', 'FILMCONFECT', 'FILMEDIA', 'FILMJUWELEN', 'FILMOTEKA NARODAWA', 'FILMRISE', 'FINAL CUT ENTERTAINMENT', 'FINAL CUT', 'FIREHOUSE 12 RECORDS', 'FIREHOUSE 12', 'FIRST INTERNATIONAL PRODUCTION', 'FIRST INTERNATIONAL', 'FIRST LOOK STUDIOS', 'FIRST LOOK', 'FLAGMAN TRADE', 'FLASHSTAR FILMES', 'FLASHSTAR', 'FLICKER ALLEY', 'FNC ADD CULTURE', 'FOCUS FILMES', 'FOCUS', 'FOKUS MEDIA', 'FOKUSA', 'FOX PATHE EUROPA', 'FOX PATHE', 'FOX EUROPA', 'FOX/MGM', 'FOX MGM', 'MGM', 'MGM/FOX', 'FOX', 'FPE', 'FRANCE TÉLÉVISIONS DISTRIBUTION', 'FRANCE TELEVISIONS DISTRIBUTION', 'FRANCE TELEVISIONS', 'FRANCE', 'FREE DOLPHIN ENTERTAINMENT', 'FREE DOLPHIN', 'FREESTYLE DIGITAL MEDIA', 'FREESTYLE DIGITAL', 'FREESTYLE', 'FREMANTLE HOME ENTERTAINMENT', 'FREMANTLE ENTERTAINMENT', 'FREMANTLE HOME', 'FREMANTL', 'FRENETIC FILMS', 'FRENETIC', 'FRONTIER WORKS', 'FRONTIER', 'FRONTIERS MUSIC', 'FRONTIERS RECORDS', 'FS FILM OY', 'FS FILM', 'FULL MOON FEATURES', 'FULL MOON', 'FUN CITY EDITIONS', 'FUN CITY', + 'FUNIMATION ENTERTAINMENT', 'FUNIMATION', 'FUSION', 'FUTUREFILM', 'G2 PICTURES', 'G2', 'GAGA COMMUNICATIONS', 'GAGA', 'GAIAM', 'GALAPAGOS', 'GAMMA HOME ENTERTAINMENT', 'GAMMA ENTERTAINMENT', 'GAMMA HOME', 'GAMMA', 'GARAGEHOUSE PICTURES', 'GARAGEHOUSE', 'GARAGEPLAY (車庫娛樂)', '車庫娛樂', 'GARAGEPLAY (Che Ku Yu Le )', 'GARAGEPLAY', 'Che Ku Yu Le', 'GAUMONT', 'GEFFEN', 'GENEON ENTERTAINMENT', 'GENEON', 'GENEON UNIVERSAL ENTERTAINMENT', 'GENERAL VIDEO RECORDING', 'GLASS DOLL FILMS', 'GLASS DOLL', 'GLOBE MUSIC MEDIA', 'GLOBE MUSIC', 'GLOBE MEDIA', 'GLOBE', 'GO ENTERTAIN', 'GO', 'GOLDEN HARVEST', 'GOOD!MOVIES', 'GOOD! MOVIES', 'GOOD MOVIES', 'GRAPEVINE VIDEO', 'GRAPEVINE', 'GRASSHOPPER FILM', 'GRASSHOPPER FILMS', 'GRASSHOPPER', 'GRAVITAS VENTURES', 'GRAVITAS', 'GREAT MOVIES', 'GREAT', 'GREEN APPLE ENTERTAINMENT', 'GREEN ENTERTAINMENT', 'GREEN APPLE', 'GREEN', 'GREENNARAE MEDIA', 'GREENNARAE', 'GRINDHOUSE RELEASING', 'GRINDHOUSE', 'GRIND HOUSE', 'GRYPHON ENTERTAINMENT', 'GRYPHON', 'GUNPOWDER & SKY', 'GUNPOWDER AND SKY', 'GUNPOWDER SKY', 'GUNPOWDER + SKY', 'GUNPOWDER', 'HANABEE ENTERTAINMENT', 'HANABEE', 'HANNOVER HOUSE', 'HANNOVER', 'HANSESOUND', 'HANSE SOUND', 'HANSE', 'HAPPINET', 'HARMONIA MUNDI', 'HARMONIA', 'HBO', 'HDC', 'HEC', 'HELL & BACK RECORDINGS', 'HELL AND BACK RECORDINGS', 'HELL & BACK', 'HELL AND BACK', "HEN'S TOOTH VIDEO", 'HENS TOOTH VIDEO', "HEN'S TOOTH", 'HENS TOOTH', 'HIGH FLIERS', 'HIGHLIGHT', 'HILLSONG', 'HISTORY CHANNEL', 'HISTORY', 'HK VIDÉO', 'HK VIDEO', 'HK', 'HMH HAMBURGER MEDIEN HAUS', 'HAMBURGER MEDIEN HAUS', 'HMH HAMBURGER MEDIEN', 'HMH HAMBURGER', 'HMH', 'HOLLYWOOD CLASSIC ENTERTAINMENT', 'HOLLYWOOD CLASSIC', 'HOLLYWOOD PICTURES', 'HOLLYWOOD', 'HOPSCOTCH ENTERTAINMENT', 'HOPSCOTCH', 'HPM', 'HÄNNSLER CLASSIC', 'HANNSLER CLASSIC', 'HANNSLER', 'I-CATCHER', 'I CATCHER', 'ICATCHER', 'I-ON NEW MEDIA', 'I ON NEW MEDIA', 'ION NEW MEDIA', 'ION MEDIA', 'I-ON', 'ION', 'IAN PRODUCTIONS', 'IAN', 'ICESTORM', 'ICON FILM DISTRIBUTION', 'ICON DISTRIBUTION', 'ICON FILM', 'ICON', 'IDEALE AUDIENCE', 'IDEALE', 'IFC FILMS', 'IFC', 'IFILM', 'ILLUSIONS UNLTD.', 'ILLUSIONS UNLTD', 'ILLUSIONS', 'IMAGE ENTERTAINMENT', 'IMAGE', 'IMAGEM FILMES', 'IMAGEM', 'IMOVISION', 'IMPERIAL CINEPIX', 'IMPRINT', 'IMPULS HOME ENTERTAINMENT', 'IMPULS ENTERTAINMENT', 'IMPULS HOME', 'IMPULS', 'IN-AKUSTIK', 'IN AKUSTIK', 'INAKUSTIK', 'INCEPTION MEDIA GROUP', 'INCEPTION MEDIA', 'INCEPTION GROUP', 'INCEPTION', 'INDEPENDENT', 'INDICAN', 'INDIE RIGHTS', 'INDIE', 'INDIGO', 'INFO', 'INJOINGAN', 'INKED PICTURES', 'INKED', 'INSIDE OUT MUSIC', 'INSIDE MUSIC', 'INSIDE OUT', 'INSIDE', 'INTERCOM', 'INTERCONTINENTAL VIDEO', 'INTERCONTINENTAL', 'INTERGROOVE', 'INTERSCOPE', 'INVINCIBLE PICTURES', 'INVINCIBLE', 'ISLAND/MERCURY', 'ISLAND MERCURY', 'ISLANDMERCURY', 'ISLAND & MERCURY', 'ISLAND AND MERCURY', 'ISLAND', 'ITN', 'ITV DVD', 'ITV', 'IVC', 'IVE ENTERTAINMENT', 'IVE', 'J&R ADVENTURES', 'J&R', 'JR', 'JAKOB', 'JONU MEDIA', 'JONU', 'JRB PRODUCTIONS', 'JRB', 'JUST BRIDGE ENTERTAINMENT', 'JUST BRIDGE', 'JUST ENTERTAINMENT', 'JUST', 'KABOOM ENTERTAINMENT', 'KABOOM', 'KADOKAWA ENTERTAINMENT', 'KADOKAWA', 'KAIROS', 'KALEIDOSCOPE ENTERTAINMENT', 'KALEIDOSCOPE', 'KAM & RONSON ENTERPRISES', 'KAM & RONSON', 'KAM&RONSON ENTERPRISES', 'KAM&RONSON', 'KAM AND RONSON ENTERPRISES', 'KAM AND RONSON', 'KANA HOME VIDEO', 'KARMA FILMS', 'KARMA', 'KATZENBERGER', 'KAZE', + 'KBS MEDIA', 'KBS', 'KD MEDIA', 'KD', 'KING MEDIA', 'KING', 'KING RECORDS', 'KINO LORBER', 'KINO', 'KINO SWIAT', 'KINOKUNIYA', 'KINOWELT HOME ENTERTAINMENT/DVD', 'KINOWELT HOME ENTERTAINMENT', 'KINOWELT ENTERTAINMENT', 'KINOWELT HOME DVD', 'KINOWELT ENTERTAINMENT/DVD', 'KINOWELT DVD', 'KINOWELT', 'KIT PARKER FILMS', 'KIT PARKER', 'KITTY MEDIA', 'KNM HOME ENTERTAINMENT', 'KNM ENTERTAINMENT', 'KNM HOME', 'KNM', 'KOBA FILMS', 'KOBA', 'KOCH ENTERTAINMENT', 'KOCH MEDIA', 'KOCH', 'KRAKEN RELEASING', 'KRAKEN', 'KSCOPE', 'KSM', 'KULTUR', "L'ATELIER D'IMAGES", "LATELIER D'IMAGES", "L'ATELIER DIMAGES", 'LATELIER DIMAGES', "L ATELIER D'IMAGES", "L'ATELIER D IMAGES", + 'L ATELIER D IMAGES', "L'ATELIER", 'L ATELIER', 'LATELIER', 'LA AVENTURA AUDIOVISUAL', 'LA AVENTURA', 'LACE GROUP', 'LACE', 'LASER PARADISE', 'LAYONS', 'LCJ EDITIONS', 'LCJ', 'LE CHAT QUI FUME', 'LE PACTE', 'LEDICK FILMHANDEL', 'LEGEND', 'LEOMARK STUDIOS', 'LEOMARK', 'LEONINE FILMS', 'LEONINE', 'LICHTUNG MEDIA LTD', 'LICHTUNG LTD', 'LICHTUNG MEDIA LTD.', 'LICHTUNG LTD.', 'LICHTUNG MEDIA', 'LICHTUNG', 'LIGHTHOUSE HOME ENTERTAINMENT', 'LIGHTHOUSE ENTERTAINMENT', 'LIGHTHOUSE HOME', 'LIGHTHOUSE', 'LIGHTYEAR', 'LIONSGATE FILMS', 'LIONSGATE', 'LIZARD CINEMA TRADE', 'LLAMENTOL', 'LOBSTER FILMS', 'LOBSTER', 'LOGON', 'LORBER FILMS', 'LORBER', 'LOS BANDITOS FILMS', 'LOS BANDITOS', 'LOUD & PROUD RECORDS', 'LOUD AND PROUD RECORDS', 'LOUD & PROUD', 'LOUD AND PROUD', 'LSO LIVE', 'LUCASFILM', 'LUCKY RED', 'LUMIÈRE HOME ENTERTAINMENT', 'LUMIERE HOME ENTERTAINMENT', 'LUMIERE ENTERTAINMENT', 'LUMIERE HOME', 'LUMIERE', 'M6 VIDEO', 'M6', 'MAD DIMENSION', 'MADMAN ENTERTAINMENT', 'MADMAN', 'MAGIC BOX', 'MAGIC PLAY', 'MAGNA HOME ENTERTAINMENT', 'MAGNA ENTERTAINMENT', 'MAGNA HOME', 'MAGNA', 'MAGNOLIA PICTURES', 'MAGNOLIA', 'MAIDEN JAPAN', 'MAIDEN', 'MAJENG MEDIA', 'MAJENG', 'MAJESTIC HOME ENTERTAINMENT', 'MAJESTIC ENTERTAINMENT', 'MAJESTIC HOME', 'MAJESTIC', 'MANGA HOME ENTERTAINMENT', 'MANGA ENTERTAINMENT', 'MANGA HOME', 'MANGA', 'MANTA LAB', 'MAPLE STUDIOS', 'MAPLE', 'MARCO POLO PRODUCTION', 'MARCO POLO', 'MARIINSKY', 'MARVEL STUDIOS', 'MARVEL', 'MASCOT RECORDS', 'MASCOT', 'MASSACRE VIDEO', 'MASSACRE', 'MATCHBOX', 'MATRIX D', 'MAXAM', 'MAYA HOME ENTERTAINMENT', 'MAYA ENTERTAINMENT', 'MAYA HOME', 'MAYAT', 'MDG', 'MEDIA BLASTERS', 'MEDIA FACTORY', 'MEDIA TARGET DISTRIBUTION', 'MEDIA TARGET', 'MEDIAINVISION', 'MEDIATOON', 'MEDIATRES ESTUDIO', 'MEDIATRES STUDIO', 'MEDIATRES', 'MEDICI ARTS', 'MEDICI CLASSICS', 'MEDIUMRARE ENTERTAINMENT', 'MEDIUMRARE', 'MEDUSA', 'MEGASTAR', 'MEI AH', 'MELI MÉDIAS', 'MELI MEDIAS', 'MEMENTO FILMS', 'MEMENTO', 'MENEMSHA FILMS', 'MENEMSHA', 'MERCURY', 'MERCURY STUDIOS', 'MERGE SOFT PRODUCTIONS', 'MERGE PRODUCTIONS', 'MERGE SOFT', 'MERGE', 'METAL BLADE RECORDS', 'METAL BLADE', 'METEOR', 'METRO-GOLDWYN-MAYER', 'METRO GOLDWYN MAYER', 'METROGOLDWYNMAYER', 'METRODOME VIDEO', 'METRODOME', 'METROPOLITAN', 'MFA+', 'MFA', 'MIG FILMGROUP', 'MIG', 'MILESTONE', 'MILL CREEK ENTERTAINMENT', 'MILL CREEK', 'MILLENNIUM MEDIA', 'MILLENNIUM', 'MIRAGE ENTERTAINMENT', 'MIRAGE', 'MIRAMAX', 'MISTERIYA ZVUKA', 'MK2', 'MODE RECORDS', 'MODE', 'MOMENTUM PICTURES', 'MONDO HOME ENTERTAINMENT', 'MONDO ENTERTAINMENT', 'MONDO HOME', 'MONDO MACABRO', 'MONGREL MEDIA', 'MONOLIT', 'MONOLITH VIDEO', 'MONOLITH', 'MONSTER PICTURES', 'MONSTER', 'MONTEREY VIDEO', 'MONTEREY', 'MONUMENT RELEASING', 'MONUMENT', 'MORNINGSTAR', 'MORNING STAR', 'MOSERBAER', 'MOVIEMAX', 'MOVINSIDE', 'MPI MEDIA GROUP', 'MPI MEDIA', 'MPI', 'MR. BONGO FILMS', 'MR BONGO FILMS', 'MR BONGO', 'MRG (MERIDIAN)', 'MRG MERIDIAN', 'MRG', 'MERIDIAN', 'MUBI', 'MUG SHOT PRODUCTIONS', 'MUG SHOT', 'MULTIMUSIC', 'MULTI-MUSIC', 'MULTI MUSIC', 'MUSE', 'MUSIC BOX FILMS', 'MUSIC BOX', 'MUSICBOX', 'MUSIC BROKERS', 'MUSIC THEORIES', 'MUSIC VIDEO DISTRIBUTORS', 'MUSIC VIDEO', 'MUSTANG ENTERTAINMENT', 'MUSTANG', 'MVD VISUAL', 'MVD', 'MVD/VSC', 'MVL', 'MVM ENTERTAINMENT', 'MVM', 'MYNDFORM', 'MYSTIC NIGHT PICTURES', 'MYSTIC NIGHT', 'NAMELESS MEDIA', 'NAMELESS', 'NAPALM RECORDS', 'NAPALM', 'NATIONAL ENTERTAINMENT MEDIA', 'NATIONAL ENTERTAINMENT', 'NATIONAL MEDIA', 'NATIONAL FILM ARCHIVE', 'NATIONAL ARCHIVE', 'NATIONAL FILM', 'NATIONAL GEOGRAPHIC', 'NAT GEO TV', 'NAT GEO', 'NGO', 'NAXOS', 'NBCUNIVERSAL ENTERTAINMENT JAPAN', 'NBC UNIVERSAL ENTERTAINMENT JAPAN', 'NBCUNIVERSAL JAPAN', 'NBC UNIVERSAL JAPAN', 'NBC JAPAN', 'NBO ENTERTAINMENT', 'NBO', 'NEOS', 'NETFLIX', 'NETWORK', 'NEW BLOOD', 'NEW DISC', 'NEW KSM', 'NEW LINE CINEMA', 'NEW LINE', 'NEW MOVIE TRADING CO. LTD', 'NEW MOVIE TRADING CO LTD', 'NEW MOVIE TRADING CO', 'NEW MOVIE TRADING', 'NEW WAVE FILMS', 'NEW WAVE', 'NFI', 'NHK', 'NIPPONART', 'NIS AMERICA', 'NJUTAFILMS', 'NOBLE ENTERTAINMENT', 'NOBLE', 'NORDISK FILM', 'NORDISK', 'NORSK FILM', 'NORSK', 'NORTH AMERICAN MOTION PICTURES', 'NOS AUDIOVISUAIS', 'NOTORIOUS PICTURES', 'NOTORIOUS', 'NOVA MEDIA', 'NOVA', 'NOVA SALES AND DISTRIBUTION', 'NOVA SALES & DISTRIBUTION', 'NSM', 'NSM RECORDS', 'NUCLEAR BLAST', 'NUCLEUS FILMS', 'NUCLEUS', 'OBERLIN MUSIC', 'OBERLIN', 'OBRAS-PRIMAS DO CINEMA', 'OBRAS PRIMAS DO CINEMA', 'OBRASPRIMAS DO CINEMA', 'OBRAS-PRIMAS CINEMA', 'OBRAS PRIMAS CINEMA', 'OBRASPRIMAS CINEMA', 'OBRAS-PRIMAS', 'OBRAS PRIMAS', 'OBRASPRIMAS', 'ODEON', 'OFDB FILMWORKS', 'OFDB', 'OLIVE FILMS', 'OLIVE', 'ONDINE', 'ONSCREEN FILMS', 'ONSCREEN', 'OPENING DISTRIBUTION', 'OPERA AUSTRALIA', 'OPTIMUM HOME ENTERTAINMENT', 'OPTIMUM ENTERTAINMENT', 'OPTIMUM HOME', 'OPTIMUM', 'OPUS ARTE', 'ORANGE STUDIO', 'ORANGE', 'ORLANDO EASTWOOD FILMS', 'ORLANDO FILMS', 'ORLANDO EASTWOOD', 'ORLANDO', 'ORUSTAK PICTURES', 'ORUSTAK', 'OSCILLOSCOPE PICTURES', 'OSCILLOSCOPE', 'OUTPLAY', 'PALISADES TARTAN', 'PAN VISION', 'PANVISION', 'PANAMINT CINEMA', 'PANAMINT', 'PANDASTORM ENTERTAINMENT', 'PANDA STORM ENTERTAINMENT', 'PANDASTORM', 'PANDA STORM', 'PANDORA FILM', 'PANDORA', 'PANEGYRIC', 'PANORAMA', 'PARADE DECK FILMS', 'PARADE DECK', 'PARADISE', 'PARADISO FILMS', 'PARADOX', 'PARAMOUNT PICTURES', 'PARAMOUNT', 'PARIS FILMES', 'PARIS FILMS', 'PARIS', 'PARK CIRCUS', 'PARLOPHONE', 'PASSION RIVER', 'PATHE DISTRIBUTION', 'PATHE', 'PBS', 'PEACE ARCH TRINITY', 'PECCADILLO PICTURES', 'PEPPERMINT', 'PHASE 4 FILMS', 'PHASE 4', 'PHILHARMONIA BAROQUE', 'PICTURE HOUSE ENTERTAINMENT', 'PICTURE ENTERTAINMENT', 'PICTURE HOUSE', 'PICTURE', 'PIDAX', + 'PINK FLOYD RECORDS', 'PINK FLOYD', 'PINNACLE FILMS', 'PINNACLE', 'PLAIN', 'PLATFORM ENTERTAINMENT LIMITED', 'PLATFORM ENTERTAINMENT LTD', 'PLATFORM ENTERTAINMENT LTD.', 'PLATFORM ENTERTAINMENT', 'PLATFORM', 'PLAYARTE', 'PLG UK CLASSICS', 'PLG UK', 'PLG', 'POLYBAND & TOPPIC VIDEO/WVG', 'POLYBAND AND TOPPIC VIDEO/WVG', 'POLYBAND & TOPPIC VIDEO WVG', 'POLYBAND & TOPPIC VIDEO AND WVG', 'POLYBAND & TOPPIC VIDEO & WVG', 'POLYBAND AND TOPPIC VIDEO WVG', 'POLYBAND AND TOPPIC VIDEO AND WVG', 'POLYBAND AND TOPPIC VIDEO & WVG', 'POLYBAND & TOPPIC VIDEO', 'POLYBAND AND TOPPIC VIDEO', 'POLYBAND & TOPPIC', 'POLYBAND AND TOPPIC', 'POLYBAND', 'WVG', 'POLYDOR', 'PONY', 'PONY CANYON', 'POTEMKINE', 'POWERHOUSE FILMS', 'POWERHOUSE', 'POWERSTATIOM', 'PRIDE & JOY', 'PRIDE AND JOY', 'PRINZ MEDIA', 'PRINZ', 'PRIS AUDIOVISUAIS', 'PRO VIDEO', 'PRO-VIDEO', 'PRO-MOTION', 'PRO MOTION', 'PROD. JRB', 'PROD JRB', 'PRODISC', 'PROKINO', 'PROVOGUE RECORDS', 'PROVOGUE', 'PROWARE', 'PULP VIDEO', 'PULP', 'PULSE VIDEO', 'PULSE', 'PURE AUDIO RECORDINGS', 'PURE AUDIO', 'PURE FLIX ENTERTAINMENT', 'PURE FLIX', 'PURE ENTERTAINMENT', 'PYRAMIDE VIDEO', 'PYRAMIDE', 'QUALITY FILMS', 'QUALITY', 'QUARTO VALLEY RECORDS', 'QUARTO VALLEY', 'QUESTAR', 'R SQUARED FILMS', 'R SQUARED', 'RAPID EYE MOVIES', 'RAPID EYE', 'RARO VIDEO', 'RARO', 'RAROVIDEO U.S.', 'RAROVIDEO US', 'RARO VIDEO US', 'RARO VIDEO U.S.', 'RARO U.S.', 'RARO US', 'RAVEN BANNER RELEASING', 'RAVEN BANNER', 'RAVEN', 'RAZOR DIGITAL ENTERTAINMENT', 'RAZOR DIGITAL', 'RCA', 'RCO LIVE', 'RCO', 'RCV', 'REAL GONE MUSIC', 'REAL GONE', 'REANIMEDIA', 'REANI MEDIA', 'REDEMPTION', 'REEL', 'RELIANCE HOME VIDEO & GAMES', 'RELIANCE HOME VIDEO AND GAMES', 'RELIANCE HOME VIDEO', 'RELIANCE VIDEO', 'RELIANCE HOME', 'RELIANCE', 'REM CULTURE', 'REMAIN IN LIGHT', 'REPRISE', 'RESEN', 'RETROMEDIA', 'REVELATION FILMS LTD.', 'REVELATION FILMS LTD', 'REVELATION FILMS', 'REVELATION LTD.', 'REVELATION LTD', 'REVELATION', 'REVOLVER ENTERTAINMENT', 'REVOLVER', 'RHINO MUSIC', 'RHINO', 'RHV', 'RIGHT STUF', 'RIMINI EDITIONS', 'RISING SUN MEDIA', 'RLJ ENTERTAINMENT', 'RLJ', 'ROADRUNNER RECORDS', 'ROADSHOW ENTERTAINMENT', 'ROADSHOW', 'RONE', 'RONIN FLIX', 'ROTANA HOME ENTERTAINMENT', 'ROTANA ENTERTAINMENT', 'ROTANA HOME', 'ROTANA', 'ROUGH TRADE', + 'ROUNDER', 'SAFFRON HILL FILMS', 'SAFFRON HILL', 'SAFFRON', 'SAMUEL GOLDWYN FILMS', 'SAMUEL GOLDWYN', 'SAN FRANCISCO SYMPHONY', 'SANDREW METRONOME', 'SAPHRANE', 'SAVOR', 'SCANBOX ENTERTAINMENT', 'SCANBOX', 'SCENIC LABS', 'SCHRÖDERMEDIA', 'SCHRODERMEDIA', 'SCHRODER MEDIA', 'SCORPION RELEASING', 'SCORPION', 'SCREAM TEAM RELEASING', 'SCREAM TEAM', 'SCREEN MEDIA', 'SCREEN', 'SCREENBOUND PICTURES', 'SCREENBOUND', 'SCREENWAVE MEDIA', 'SCREENWAVE', 'SECOND RUN', 'SECOND SIGHT', 'SEEDSMAN GROUP', 'SELECT VIDEO', 'SELECTA VISION', 'SENATOR', 'SENTAI FILMWORKS', 'SENTAI', 'SEVEN7', 'SEVERIN FILMS', 'SEVERIN', 'SEVILLE', 'SEYONS ENTERTAINMENT', 'SEYONS', 'SF STUDIOS', 'SGL ENTERTAINMENT', 'SGL', 'SHAMELESS', 'SHAMROCK MEDIA', 'SHAMROCK', 'SHANGHAI EPIC MUSIC ENTERTAINMENT', 'SHANGHAI EPIC ENTERTAINMENT', 'SHANGHAI EPIC MUSIC', 'SHANGHAI MUSIC ENTERTAINMENT', 'SHANGHAI ENTERTAINMENT', 'SHANGHAI MUSIC', 'SHANGHAI', 'SHEMAROO', 'SHOCHIKU', 'SHOCK', 'SHOGAKU KAN', 'SHOUT FACTORY', 'SHOUT! FACTORY', 'SHOUT', 'SHOUT!', 'SHOWBOX', 'SHOWTIME ENTERTAINMENT', 'SHOWTIME', 'SHRIEK SHOW', 'SHUDDER', 'SIDONIS', 'SIDONIS CALYSTA', 'SIGNAL ONE ENTERTAINMENT', 'SIGNAL ONE', 'SIGNATURE ENTERTAINMENT', 'SIGNATURE', 'SILVER VISION', 'SINISTER FILM', 'SINISTER', 'SIREN VISUAL ENTERTAINMENT', 'SIREN VISUAL', 'SIREN ENTERTAINMENT', 'SIREN', 'SKANI', 'SKY DIGI', + 'SLASHER // VIDEO', 'SLASHER / VIDEO', 'SLASHER VIDEO', 'SLASHER', 'SLOVAK FILM INSTITUTE', 'SLOVAK FILM', 'SFI', 'SM LIFE DESIGN GROUP', 'SMOOTH PICTURES', 'SMOOTH', 'SNAPPER MUSIC', 'SNAPPER', 'SODA PICTURES', 'SODA', 'SONO LUMINUS', 'SONY MUSIC', 'SONY PICTURES', 'SONY', 'SONY PICTURES CLASSICS', 'SONY CLASSICS', 'SOUL MEDIA', 'SOUL', 'SOULFOOD MUSIC DISTRIBUTION', 'SOULFOOD DISTRIBUTION', 'SOULFOOD MUSIC', 'SOULFOOD', 'SOYUZ', 'SPECTRUM', 'SPENTZOS FILM', 'SPENTZOS', 'SPIRIT ENTERTAINMENT', 'SPIRIT', 'SPIRIT MEDIA GMBH', 'SPIRIT MEDIA', 'SPLENDID ENTERTAINMENT', 'SPLENDID FILM', 'SPO', 'SQUARE ENIX', 'SRI BALAJI VIDEO', 'SRI BALAJI', 'SRI', 'SRI VIDEO', 'SRS CINEMA', 'SRS', 'SSO RECORDINGS', 'SSO', 'ST2 MUSIC', 'ST2', 'STAR MEDIA ENTERTAINMENT', 'STAR ENTERTAINMENT', 'STAR MEDIA', 'STAR', 'STARLIGHT', 'STARZ / ANCHOR BAY', 'STARZ ANCHOR BAY', 'STARZ', 'ANCHOR BAY', 'STER KINEKOR', 'STERLING ENTERTAINMENT', 'STERLING', 'STINGRAY', 'STOCKFISCH RECORDS', 'STOCKFISCH', 'STRAND RELEASING', 'STRAND', 'STUDIO 4K', 'STUDIO CANAL', 'STUDIO GHIBLI', 'GHIBLI', 'STUDIO HAMBURG ENTERPRISES', 'HAMBURG ENTERPRISES', 'STUDIO HAMBURG', 'HAMBURG', 'STUDIO S', 'SUBKULTUR ENTERTAINMENT', 'SUBKULTUR', 'SUEVIA FILMS', 'SUEVIA', 'SUMMIT ENTERTAINMENT', 'SUMMIT', 'SUNFILM ENTERTAINMENT', 'SUNFILM', 'SURROUND RECORDS', 'SURROUND', 'SVENSK FILMINDUSTRI', 'SVENSK', 'SWEN FILMES', 'SWEN FILMS', 'SWEN', 'SYNAPSE FILMS', 'SYNAPSE', 'SYNDICADO', 'SYNERGETIC', 'T- SERIES', 'T-SERIES', 'T SERIES', 'TSERIES', 'T.V.P.', 'TVP', 'TACET RECORDS', 'TACET', 'TAI SENG', 'TAI SHENG', 'TAKEONE', 'TAKESHOBO', 'TAMASA DIFFUSION', 'TC ENTERTAINMENT', 'TC', 'TDK', 'TEAM MARKETING', 'TEATRO REAL', 'TEMA DISTRIBUCIONES', 'TEMPE DIGITAL', 'TF1 VIDÉO', 'TF1 VIDEO', 'TF1', 'THE BLU', 'BLU', 'THE ECSTASY OF FILMS', 'THE FILM DETECTIVE', 'FILM DETECTIVE', 'THE JOKERS', 'JOKERS', 'THE ON', 'ON', 'THIMFILM', 'THIM FILM', 'THIM', 'THIRD WINDOW FILMS', 'THIRD WINDOW', '3RD WINDOW FILMS', '3RD WINDOW', 'THUNDERBEAN ANIMATION', 'THUNDERBEAN', 'THUNDERBIRD RELEASING', 'THUNDERBIRD', 'TIBERIUS FILM', 'TIME LIFE', 'TIMELESS MEDIA GROUP', 'TIMELESS MEDIA', 'TIMELESS GROUP', 'TIMELESS', 'TLA RELEASING', 'TLA', 'TOBIS FILM', 'TOBIS', 'TOEI', 'TOHO', 'TOKYO SHOCK', 'TOKYO', 'TONPOOL MEDIEN GMBH', 'TONPOOL MEDIEN', 'TOPICS ENTERTAINMENT', 'TOPICS', 'TOUCHSTONE PICTURES', 'TOUCHSTONE', 'TRANSMISSION FILMS', 'TRANSMISSION', 'TRAVEL VIDEO STORE', 'TRIART', 'TRIGON FILM', 'TRIGON', 'TRINITY HOME ENTERTAINMENT', 'TRINITY ENTERTAINMENT', 'TRINITY HOME', 'TRINITY', 'TRIPICTURES', 'TRI-PICTURES', 'TRI PICTURES', 'TROMA', 'TURBINE MEDIEN', 'TURTLE RECORDS', 'TURTLE', 'TVA FILMS', 'TVA', 'TWILIGHT TIME', 'TWILIGHT', 'TT', 'TWIN CO., LTD.', 'TWIN CO, LTD.', 'TWIN CO., LTD', 'TWIN CO, LTD', 'TWIN CO LTD', 'TWIN LTD', 'TWIN CO.', 'TWIN CO', 'TWIN', 'UCA', 'UDR', 'UEK', 'UFA/DVD', 'UFA DVD', 'UFADVD', 'UGC PH', 'ULTIMATE3DHEAVEN', 'ULTRA', 'UMBRELLA ENTERTAINMENT', 'UMBRELLA', 'UMC', "UNCORK'D ENTERTAINMENT", 'UNCORKD ENTERTAINMENT', 'UNCORK D ENTERTAINMENT', "UNCORK'D", 'UNCORK D', 'UNCORKD', 'UNEARTHED FILMS', 'UNEARTHED', 'UNI DISC', 'UNIMUNDOS', 'UNITEL', 'UNIVERSAL MUSIC', 'UNIVERSAL SONY PICTURES HOME ENTERTAINMENT', 'UNIVERSAL SONY PICTURES ENTERTAINMENT', 'UNIVERSAL SONY PICTURES HOME', 'UNIVERSAL SONY PICTURES', 'UNIVERSAL HOME ENTERTAINMENT', 'UNIVERSAL ENTERTAINMENT', + 'UNIVERSAL HOME', 'UNIVERSAL STUDIOS', 'UNIVERSAL', 'UNIVERSE LASER & VIDEO CO.', 'UNIVERSE LASER AND VIDEO CO.', 'UNIVERSE LASER & VIDEO CO', 'UNIVERSE LASER AND VIDEO CO', 'UNIVERSE LASER CO.', 'UNIVERSE LASER CO', 'UNIVERSE LASER', 'UNIVERSUM FILM', 'UNIVERSUM', 'UTV', 'VAP', 'VCI', 'VENDETTA FILMS', 'VENDETTA', 'VERSÁTIL HOME VIDEO', 'VERSÁTIL VIDEO', 'VERSÁTIL HOME', 'VERSÁTIL', 'VERSATIL HOME VIDEO', 'VERSATIL VIDEO', 'VERSATIL HOME', 'VERSATIL', 'VERTICAL ENTERTAINMENT', 'VERTICAL', 'VÉRTICE 360º', 'VÉRTICE 360', 'VERTICE 360o', 'VERTICE 360', 'VERTIGO BERLIN', 'VÉRTIGO FILMS', 'VÉRTIGO', 'VERTIGO FILMS', 'VERTIGO', 'VERVE PICTURES', 'VIA VISION ENTERTAINMENT', 'VIA VISION', 'VICOL ENTERTAINMENT', 'VICOL', 'VICOM', 'VICTOR ENTERTAINMENT', 'VICTOR', 'VIDEA CDE', 'VIDEO FILM EXPRESS', 'VIDEO FILM', 'VIDEO EXPRESS', 'VIDEO MUSIC, INC.', 'VIDEO MUSIC, INC', 'VIDEO MUSIC INC.', 'VIDEO MUSIC INC', 'VIDEO MUSIC', 'VIDEO SERVICE CORP.', 'VIDEO SERVICE CORP', 'VIDEO SERVICE', 'VIDEO TRAVEL', 'VIDEOMAX', 'VIDEO MAX', 'VII PILLARS ENTERTAINMENT', 'VII PILLARS', 'VILLAGE FILMS', 'VINEGAR SYNDROME', 'VINEGAR', 'VS', 'VINNY MOVIES', 'VINNY', 'VIRGIL FILMS & ENTERTAINMENT', 'VIRGIL FILMS AND ENTERTAINMENT', 'VIRGIL ENTERTAINMENT', 'VIRGIL FILMS', 'VIRGIL', 'VIRGIN RECORDS', 'VIRGIN', 'VISION FILMS', 'VISION', 'VISUAL ENTERTAINMENT GROUP', + 'VISUAL GROUP', 'VISUAL ENTERTAINMENT', 'VISUAL', 'VIVENDI VISUAL ENTERTAINMENT', 'VIVENDI VISUAL', 'VIVENDI', 'VIZ PICTURES', 'VIZ', 'VLMEDIA', 'VL MEDIA', 'VL', 'VOLGA', 'VVS FILMS', 'VVS', 'VZ HANDELS GMBH', 'VZ HANDELS', 'WARD RECORDS', 'WARD', 'WARNER BROS.', 'WARNER BROS', 'WARNER ARCHIVE', 'WARNER ARCHIVE COLLECTION', 'WAC', 'WARNER', 'WARNER MUSIC', 'WEA', 'WEINSTEIN COMPANY', 'WEINSTEIN', 'WELL GO USA', 'WELL GO', 'WELTKINO FILMVERLEIH', 'WEST VIDEO', 'WEST', 'WHITE PEARL MOVIES', 'WHITE PEARL', 'WICKED-VISION MEDIA', 'WICKED VISION MEDIA', 'WICKEDVISION MEDIA', 'WICKED-VISION', 'WICKED VISION', 'WICKEDVISION', 'WIENERWORLD', 'WILD BUNCH', 'WILD EYE RELEASING', 'WILD EYE', 'WILD SIDE VIDEO', 'WILD SIDE', 'WME', 'WOLFE VIDEO', 'WOLFE', 'WORD ON FIRE', 'WORKS FILM GROUP', 'WORLD WRESTLING', 'WVG MEDIEN', 'WWE STUDIOS', 'WWE', 'X RATED KULT', 'X-RATED KULT', 'X RATED CULT', 'X-RATED CULT', 'X RATED', 'X-RATED', 'XCESS', 'XLRATOR', 'XT VIDEO', 'XT', 'YAMATO VIDEO', 'YAMATO', 'YASH RAJ FILMS', 'YASH RAJS', 'ZEITGEIST FILMS', 'ZEITGEIST', 'ZENITH PICTURES', 'ZENITH', 'ZIMA', 'ZYLO', 'ZYX MUSIC', 'ZYX', + 'MASTERS OF CINEMA', 'MOC' + ] + distributor_out = "" + if distributor_in not in [None, "None", ""]: + for each in distributor_list: + if distributor_in.upper() == each: + distributor_out = each + return distributor_out + + +async def get_service(video=None, tag=None, audio=None, guess_title=None, get_services_only=False): + services = { + '9NOW': '9NOW', '9Now': '9NOW', 'ADN': 'ADN', 'Animation Digital Network': 'ADN', 'AE': 'AE', 'A&E': 'AE', 'AJAZ': 'AJAZ', 'Al Jazeera English': 'AJAZ', + 'ALL4': 'ALL4', 'Channel 4': 'ALL4', 'AMBC': 'AMBC', 'ABC': 'AMBC', 'AMC': 'AMC', 'AMZN': 'AMZN', + 'Amazon Prime': 'AMZN', 'ANLB': 'ANLB', 'AnimeLab': 'ANLB', 'ANPL': 'ANPL', 'Animal Planet': 'ANPL', + 'AOL': 'AOL', 'ARD': 'ARD', 'AS': 'AS', 'Adult Swim': 'AS', 'ATK': 'ATK', "America's Test Kitchen": 'ATK', 'ATV': 'ATV', 'Apple TV': 'ATV', + 'ATVP': 'ATVP', 'Apple TV+': 'ATVP', 'AUBC': 'AUBC', 'ABC Australia': 'AUBC', 'BCORE': 'BCORE', 'BKPL': 'BKPL', + 'Blackpills': 'BKPL', 'BluTV': 'BLU', 'Binge': 'BNGE', 'BOOM': 'BOOM', 'Boomerang': 'BOOM', 'BRAV': 'BRAV', + 'BravoTV': 'BRAV', 'CBC': 'CBC', 'CBS': 'CBS', 'CC': 'CC', 'Comedy Central': 'CC', 'CCGC': 'CCGC', + 'Comedians in Cars Getting Coffee': 'CCGC', 'CHGD': 'CHGD', 'CHRGD': 'CHGD', 'CMAX': 'CMAX', 'Cinemax': 'CMAX', + 'CMOR': 'CMOR', 'CMT': 'CMT', 'Country Music Television': 'CMT', 'CN': 'CN', 'Cartoon Network': 'CN', 'CNBC': 'CNBC', + 'CNLP': 'CNLP', 'Canal+': 'CNLP', 'CNGO': 'CNGO', 'Cinego': 'CNGO', 'COOK': 'COOK', 'CORE': 'CORE', 'CR': 'CR', + 'Crunchy Roll': 'CR', 'Crave': 'CRAV', 'CRIT': 'CRIT', 'Criterion': 'CRIT', 'Chorki': 'CRKI', 'CRKI': 'CRKI', 'CRKL': 'CRKL', 'Crackle': 'CRKL', + 'CSPN': 'CSPN', 'CSpan': 'CSPN', 'CTV': 'CTV', 'CUR': 'CUR', 'CuriosityStream': 'CUR', 'CW': 'CW', 'The CW': 'CW', + 'CWS': 'CWS', 'CWSeed': 'CWS', 'DAZN': 'DAZN', 'DCU': 'DCU', 'DC Universe': 'DCU', 'DDY': 'DDY', + 'Digiturk Diledigin Yerde': 'DDY', 'DEST': 'DEST', 'DramaFever': 'DF', 'DHF': 'DHF', 'Deadhouse Films': 'DHF', + 'DISC': 'DISC', 'Discovery': 'DISC', 'DIY': 'DIY', 'DIY Network': 'DIY', 'DOCC': 'DOCC', 'Doc Club': 'DOCC', 'DOCPLAY': 'DOCPLAY', + 'DPLY': 'DPLY', 'DPlay': 'DPLY', 'DRPO': 'DRPO', 'Discovery Plus': 'DSCP', 'DSKI': 'DSKI', 'Daisuki': 'DSKI', + 'DSNP': 'DSNP', 'Disney+': 'DSNP', 'DSNY': 'DSNY', 'Disney': 'DSNY', 'DTV': 'DTV', 'EPIX': 'EPIX', 'ePix': 'EPIX', + 'ESPN': 'ESPN', 'ESQ': 'ESQ', 'Esquire': 'ESQ', 'ETTV': 'ETTV', 'El Trece': 'ETTV', 'ETV': 'ETV', 'E!': 'ETV', + 'FAM': 'FAM', 'Fandor': 'FANDOR', 'Facebook Watch': 'FBWatch', 'FJR': 'FJR', 'Family Jr': 'FJR', 'FMIO': 'FMIO', + 'Filmio': 'FMIO', 'FOOD': 'FOOD', 'Food Network': 'FOOD', 'FOX': 'FOX', 'Fox': 'FOX', 'Fox Premium': 'FOXP', + 'UFC Fight Pass': 'FP', 'FPT': 'FPT', 'FREE': 'FREE', 'Freeform': 'FREE', 'FTV': 'FTV', 'FUNI': 'FUNI', 'FUNi': 'FUNI', + 'Foxtel': 'FXTL', 'FYI': 'FYI', 'FYI Network': 'FYI', 'GC': 'GC', 'NHL GameCenter': 'GC', 'GLBL': 'GLBL', + 'Global': 'GLBL', 'GLBO': 'GLBO', 'Globoplay': 'GLBO', 'GLOB': 'GLOB', 'GloboSat Play': 'GLOB', 'GO90': 'GO90', 'GagaOOLala': 'Gaga', 'HBO': 'HBO', + 'HBO Go': 'HBO', 'HGTV': 'HGTV', 'HIDI': 'HIDI', 'HIST': 'HIST', 'History': 'HIST', 'HLMK': 'HLMK', 'Hallmark': 'HLMK', + 'HMAX': 'HMAX', 'HBO Max': 'HMAX', 'HS': 'HTSR', 'HTSR': 'HTSR', 'HSTR': 'Hotstar', 'HULU': 'HULU', 'Hulu': 'HULU', + 'hoichoi': 'HoiChoi', 'ID': 'ID', 'Investigation Discovery': 'ID', 'IFC': 'IFC', 'iflix': 'IFX', + 'National Audiovisual Institute': 'INA', 'ITV': 'ITV', 'JOYN': 'JOYN', 'KAYO': 'KAYO', 'KNOW': 'KNOW', 'Knowledge Network': 'KNOW', + 'KNPY': 'KNPY', 'Kanopy': 'KNPY', 'Kocowa+': 'KCW', 'Kocowa': 'KCW', 'KCW': 'KCW', 'LIFE': 'LIFE', 'Lifetime': 'LIFE', 'LN': 'LN', 'MA': 'MA', 'Looke': 'LOOKE', 'LOOKE': 'LOOKE', 'Movies Anywhere': 'MA', + 'MAX': 'MAX', 'MBC': 'MBC', 'MNBC': 'MNBC', 'MSNBC': 'MNBC', 'MTOD': 'MTOD', 'Motor Trend OnDemand': 'MTOD', 'MTV': 'MTV', + 'MUBI': 'MUBI', 'NATG': 'NATG', 'National Geographic': 'NATG', 'NBA': 'NBA', 'NBA TV': 'NBA', 'NBC': 'NBC', 'NF': 'NF', + 'Netflix': 'NF', 'National Film Board': 'NFB', 'NFL': 'NFL', 'NFLN': 'NFLN', 'NFL Now': 'NFLN', 'NICK': 'NICK', + 'Nickelodeon': 'NICK', 'NOW': 'NOW', 'NOWTV': 'NOW', 'NRK': 'NRK', 'Norsk Rikskringkasting': 'NRK', 'OnDemandKorea': 'ODK', 'Opto': 'OPTO', + 'ORF': 'ORF', 'ORF ON': 'ORF', 'Oprah Winfrey Network': 'OWN', 'PA': 'PA', 'PBS': 'PBS', 'PBSK': 'PBSK', 'PBS Kids': 'PBSK', + 'PCOK': 'PCOK', 'Peacock': 'PCOK', 'PLAY': 'PLAY', 'PLTV': 'PLTV', 'Pluto TV': 'PLTV', 'PLUZ': 'PLUZ', 'Pluzz': 'PLUZ', 'PMNP': 'PMNP', 'PMNT': 'PMNT', + 'PMTP': 'PMTP', 'POGO': 'POGO', 'PokerGO': 'POGO', 'PSN': 'PSN', 'Playstation Network': 'PSN', 'PUHU': 'PUHU', 'QIBI': 'QIBI', + 'RED': 'RED', 'YouTube Red': 'RED', 'RKTN': 'RKTN', 'Rakuten TV': 'RKTN', 'The Roku Channel': 'ROKU', 'RNET': 'RNET', + 'OBB Railnet': 'RNET', 'RSTR': 'RSTR', 'RTE': 'RTE', 'RTE One': 'RTE', 'RTLP': 'RTLP', 'RTL+': 'RTLP', 'RUUTU': 'RUUTU', + 'SBS': 'SBS', 'Science Channel': 'SCI', 'SESO': 'SESO', 'SeeSo': 'SESO', 'SHMI': 'SHMI', 'Shomi': 'SHMI', 'SKST': 'SKST', + 'SkyShowtime': 'SKST', 'SHO': 'SHO', 'Showtime': 'SHO', 'SNET': 'SNET', 'Sportsnet': 'SNET', 'Sony': 'SONY', 'SPIK': 'SPIK', + 'Spike': 'SPIK', 'Spike TV': 'SPKE', 'SPRT': 'SPRT', 'Sprout': 'SPRT', 'STAN': 'STAN', 'Stan': 'STAN', 'STARZ': 'STARZ', + 'STRP': 'STRP', 'Star+': 'STRP', 'STZ': 'STZ', 'Starz': 'STZ', 'SVT': 'SVT', 'Sveriges Television': 'SVT', 'SWER': 'SWER', + 'SwearNet': 'SWER', 'SYFY': 'SYFY', 'Syfy': 'SYFY', 'TBS': 'TBS', 'TEN': 'TEN', 'TIMV': 'TIMV', 'TIMvision': 'TIMV', + 'TFOU': 'TFOU', 'TFou': 'TFOU', 'TIMV': 'TIMV', 'TLC': 'TLC', 'TOU': 'TOU', 'TRVL': 'TRVL', 'TUBI': 'TUBI', 'TubiTV': 'TUBI', + 'TV3': 'TV3', 'TV3 Ireland': 'TV3', 'TV4': 'TV4', 'TV4 Sweeden': 'TV4', 'TVING': 'TVING', 'TVL': 'TVL', 'TV Land': 'TVL', + 'TVNZ': 'TVNZ', 'UFC': 'UFC', 'UKTV': 'UKTV', 'UNIV': 'UNIV', 'Univision': 'UNIV', 'USAN': 'USAN', 'USA Network': 'USAN', + 'VH1': 'VH1', 'VIAP': 'VIAP', 'VICE': 'VICE', 'Viceland': 'VICE', 'Viki': 'VIKI', 'VIMEO': 'VIMEO', 'Vivamax': 'VMAX', 'VMAX': 'VMAX', 'Vivaone': 'VONE', 'VONE': 'VONE', 'VLCT': 'VLCT', + 'Velocity': 'VLCT', 'VMEO': 'VMEO', 'Vimeo': 'VMEO', 'VRV': 'VRV', 'VUDU': 'VUDU', 'WME': 'WME', 'WatchMe': 'WME', 'WNET': 'WNET', + 'W Network': 'WNET', 'WWEN': 'WWEN', 'WWE Network': 'WWEN', 'XBOX': 'XBOX', 'Xbox Video': 'XBOX', 'XUMO': 'XUMO', 'YHOO': 'YHOO', 'Yahoo': 'YHOO', + 'YT': 'YT', 'ZDF': 'ZDF', 'iP': 'iP', 'BBC iPlayer': 'iP', 'iQIYI': 'iQIYI', 'iT': 'iT', 'iTunes': 'iT' + } + + if get_services_only: + return services + service = guessit(video).get('streaming_service', "") + + video_name = re.sub(r"[.()]", " ", video.replace(tag, '').replace(guess_title, '')) + if "DTS-HD MA" in audio: + video_name = video_name.replace("DTS-HD.MA.", "").replace("DTS-HD MA ", "") + for key, value in services.items(): + if (' ' + key + ' ') in video_name and key not in guessit(video, {"excludes": ["country", "language"]}).get('title', ''): + service = value + elif key == service: + service = value + service_longname = service + for key, value in services.items(): + if value == service and len(key) > len(service_longname): + service_longname = key + if service_longname == "Amazon Prime": + service_longname = "Amazon" + return service, service_longname diff --git a/src/rehostimages.py b/src/rehostimages.py new file mode 100644 index 000000000..68f13b015 --- /dev/null +++ b/src/rehostimages.py @@ -0,0 +1,512 @@ +import glob +import os +import json +import aiofiles +import asyncio +import re +from src.console import console +from urllib.parse import urlparse +from src.takescreens import disc_screenshots, dvd_screenshots, screenshots +from src.uploadscreens import upload_screens +from data.config import config + + +async def match_host(hostname, approved_hosts): + for approved_host in approved_hosts: + if hostname == approved_host or hostname.endswith(f".{approved_host}"): + return approved_host + return hostname + + +async def sanitize_filename(filename): + # Replace invalid characters like colons with an underscore + return re.sub(r'[<>:"/\\|?*]', '_', filename) + + +async def check_hosts(meta, tracker, url_host_mapping, img_host_index=1, approved_image_hosts=None): + if meta.get('skip_imghost_upload', False): + if meta['debug']: + console.print(f"[yellow]Skipping image host upload for {tracker} as per meta['skip_imghost_upload'] setting.") + return + if meta['debug']: + console.print(f"[yellow]Checking existing image hosts for {tracker}...") + new_images_key = f'{tracker}_images_key' + if new_images_key not in meta: + meta[new_images_key] = [] + + # Check if we have main image_list but no tracker-specific images yet + if meta.get('image_list') and not meta.get(new_images_key): + if meta['debug']: + console.print(f"[yellow]Checking if existing images in meta['image_list'] can be used for {tracker}...") + # Check if the URLs in image_list are from approved hosts + approved_images = [] + need_reupload = False + + for image in meta.get('image_list', []): + raw_url = image.get('raw_url') + if not raw_url: + continue + + parsed_url = urlparse(raw_url) + hostname = parsed_url.netloc + mapped_host = await match_host(hostname, url_host_mapping.keys()) + + if mapped_host: + mapped_host = url_host_mapping.get(mapped_host, mapped_host) + if mapped_host in approved_image_hosts: + approved_images.append(image) + if meta['debug']: + console.print(f"[green]URL '{raw_url}' is from approved host '{mapped_host}'.") + else: + need_reupload = True + if meta['debug']: + console.print(f"[yellow]URL '{raw_url}' is not from an approved host for {tracker}.") + else: + need_reupload = True + + # If all images are approved, use them directly + if approved_images and len(approved_images) == len(meta.get('image_list', [])) and not need_reupload: + meta[new_images_key] = approved_images.copy() + if meta['debug']: + console.print(f"[green]All existing images are from approved hosts for {tracker}.") + return meta[new_images_key], False, False + + if tracker == "covers": + reuploaded_images_path = os.path.join(meta['base_dir'], "tmp", meta['uuid'], "covers.json") + else: + reuploaded_images_path = os.path.join(meta['base_dir'], "tmp", meta['uuid'], "reuploaded_images.json") + reuploaded_images = [] + + if os.path.exists(reuploaded_images_path): + try: + async with aiofiles.open(reuploaded_images_path, 'r', encoding='utf-8') as f: + content = await f.read() + reuploaded_images = json.loads(content) + except Exception as e: + console.print(f"[red]Failed to load reuploaded images: {e}") + + valid_reuploaded_images = [] + for image in reuploaded_images: + raw_url = image.get('raw_url') + if not raw_url: + continue + + # For covers, verify the release_url matches + if tracker == "covers" and "release_url" in meta: + if "release_url" not in image or image["release_url"] != meta["release_url"]: + if meta.get('debug'): + if "release_url" not in image: + console.print(f"[yellow]Skipping image without release_url: {raw_url}") + else: + console.print(f"[yellow]Skipping image with mismatched release_url: {image['release_url']} != {meta['release_url']}") + continue + + parsed_url = urlparse(raw_url) + hostname = parsed_url.netloc + mapped_host = await match_host(hostname, url_host_mapping.keys()) + + if mapped_host: + mapped_host = url_host_mapping.get(mapped_host, mapped_host) + if mapped_host in approved_image_hosts: + valid_reuploaded_images.append(image) + elif meta['debug']: + console.print(f"[red]URL '{raw_url}' from reuploaded_images.json is not recognized as an approved host.") + + if valid_reuploaded_images: + meta[new_images_key] = valid_reuploaded_images + if tracker == "covers": + console.print("[green]Using valid images from covers.json.") + else: + console.print("[green]Using valid images from reuploaded_images.json.") + return meta[new_images_key], False, False + + # Check if the tracker-specific key has valid images + has_valid_images = False + if meta.get(new_images_key): + valid_hosts = [] + for image in meta[new_images_key]: + netloc = urlparse(image.get('raw_url', '')).netloc + matched_host = await match_host(netloc, url_host_mapping.keys()) + mapped_host = url_host_mapping.get(matched_host, None) + valid_hosts.append(mapped_host in approved_image_hosts) + + # Then check if all are valid + if all(valid_hosts) and meta[new_images_key]: + has_valid_images = True + + if has_valid_images: + console.print(f"[green]Using valid images from {new_images_key}.") + return meta[new_images_key], False, False + + if meta['debug']: + console.print(f"[yellow]No valid images found for {tracker}, will attempt to reupload...") + + images_reuploaded = False + max_retries = len(approved_image_hosts) + + while img_host_index <= max_retries: + image_list, retry_mode, images_reuploaded = await handle_image_upload( + meta, tracker, url_host_mapping, approved_image_hosts, img_host_index=img_host_index + ) + + if image_list: + meta[new_images_key] = image_list + + if retry_mode: + console.print(f"[yellow]Switching to the next image host. Current index: {img_host_index}") + img_host_index += 1 + continue # Retry with next host + + break + + if not meta.get(new_images_key): + console.print("[red]All image hosts failed. Please check your configuration.") + + return meta.get(new_images_key, []), False, images_reuploaded + + +async def handle_image_upload(meta, tracker, url_host_mapping, approved_image_hosts=None, img_host_index=1, file=None): + original_imghost = meta.get('imghost') + retry_mode = False + images_reuploaded = False + new_images_key = f'{tracker}_images_key' + discs = meta.get('discs', []) # noqa F841 + filelist = meta.get('video', []) + filename = meta['title'] + if meta.get('is_disc') == "HDDVD": + path = meta['discs'][0]['largest_evo'] + else: + path = meta.get('filelist', [None]) + path = path[0] if path else None + + if isinstance(filelist, str): + filelist = [filelist] + + multi_screens = meta.get('screens') if meta.get('screens') else int(config['DEFAULT'].get('screens', 6)) + base_dir = meta['base_dir'] + folder_id = meta['uuid'] + meta[new_images_key] = [] + + screenshots_dir = os.path.join(base_dir, 'tmp', folder_id) + if meta['debug']: + console.print(f"[yellow]Searching for screenshots in {screenshots_dir}...") + all_screenshots = [] + + # First check if there are any saved screenshots matching those in the image_list + if meta.get('image_list') and isinstance(meta['image_list'], list): + # Get all PNG files in the screenshots directory + all_png_files = await asyncio.to_thread(glob.glob, os.path.join(screenshots_dir, "*.png")) + if all_png_files and meta.get('debug'): + console.print(f"[cyan]Found {len(all_png_files)} PNG files in screenshots directory") + + # Extract filenames from the image_list + image_filenames = [] + for image in meta['image_list']: + for url_key in ['raw_url', 'img_url', 'web_url']: + if url_key in image and image[url_key]: + parsed_url = urlparse(image[url_key]) + filename_from_url = os.path.basename(parsed_url.path) + if filename_from_url and filename_from_url.lower().endswith('.png'): + image_filenames.append(filename_from_url) + break + + if image_filenames and meta.get('debug'): + console.print(f"[cyan]Extracted {len(image_filenames)} filenames from image_list URLs: {image_filenames}") + + # Check if any of the extracted filenames match the actual files in the directory + if all_png_files and image_filenames: + for png_file in all_png_files: + basename = os.path.basename(png_file) + if basename in image_filenames: + # Found a match for this filename + all_screenshots.append(png_file) + if meta.get('debug'): + console.print(f"[green]Found existing screenshot matching URL: {basename}") + + # Also check for any screenshots that match the title pattern as a fallback + if filename and len(all_screenshots) < multi_screens: + sanitized_title = await sanitize_filename(filename) + title_pattern_files = [f for f in all_png_files if os.path.basename(f).startswith(sanitized_title)] + if meta['debug']: + console.print(f"[yellow]Searching for screenshots with pattern: {sanitized_title}*.png") + if title_pattern_files: + # Only add title pattern files that aren't already in all_screenshots + for file in title_pattern_files: + if file not in all_screenshots: + all_screenshots.append(file) + + if meta.get('debug'): + console.print(f"[green]Found {len(title_pattern_files)} screenshots matching title pattern") + + # If we haven't found enough screenshots yet, search for files in the normal way + if len(all_screenshots) < multi_screens: + for i, file in enumerate(filelist): + sanitized_title = await sanitize_filename(filename) + filename_pattern = f"{sanitized_title}*.png" + if meta['debug']: + console.print(f"[yellow]Searching for screenshots with pattern: {filename_pattern}") + + if meta['is_disc'] == "DVD": + existing_screens = await asyncio.to_thread(glob.glob, f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['discs'][0]['name']}-*.png") + else: + existing_screens = await asyncio.to_thread(glob.glob, os.path.join(screenshots_dir, filename_pattern)) + + # Add any new screenshots to our list + for screen in existing_screens: + if screen not in all_screenshots: + all_screenshots.append(screen) + + # Fallback: glob for indexed screenshots if still not enough + if len(all_screenshots) < multi_screens: + os.chdir(f"{meta['base_dir']}/tmp/{meta['uuid']}") + image_patterns = ["*.png", ".[!.]*.png"] + image_glob = [] + for pattern in image_patterns: + glob_results = await asyncio.to_thread(glob.glob, pattern) + image_glob.extend(glob_results) + if meta['debug']: + console.print(f"[cyan]Found {len(image_glob)} files matching pattern: {pattern}") + + unwanted_patterns = ["FILE*", "PLAYLIST*", "POSTER*"] + unwanted_files = set() + for pattern in unwanted_patterns: + glob_results = await asyncio.to_thread(glob.glob, pattern) + unwanted_files.update(glob_results) + if pattern.startswith("FILE") or pattern.startswith("PLAYLIST") or pattern.startswith("POSTER"): + hidden_pattern = "." + pattern + hidden_glob_results = await asyncio.to_thread(glob.glob, hidden_pattern) + unwanted_files.update(hidden_glob_results) + + # Remove unwanted files + image_glob = [file for file in image_glob if file not in unwanted_files] + image_glob = list(set(image_glob)) + if meta['debug']: + console.print(f"[cyan]Filtered out {len(unwanted_files)} unwanted files, remaining: {len(image_glob)}") + + # Only keep files that match the indexed pattern: xxx-0.png, xxx-1.png, etc. + indexed_pattern = re.compile(r".*-\d+\.png$") + indexed_files = [file for file in image_glob if indexed_pattern.match(os.path.basename(file))] + if meta['debug']: + console.print(f"[cyan]Found {len(indexed_files)} indexed files matching pattern") + + # Add any new indexed screenshots to our list + for screen in indexed_files: + if screen not in all_screenshots: + all_screenshots.append(screen) + if meta.get('debug'): + console.print(f"[green]Found indexed screenshot: {os.path.basename(screen)}") + + if tracker == "covers": + all_screenshots = [] + existing_screens = await asyncio.to_thread(glob.glob, f"{meta['base_dir']}/tmp/{meta['uuid']}/cover_*.jpg") + for screen in existing_screens: + if screen not in all_screenshots: + all_screenshots.append(screen) + + # Ensure we have unique screenshots + all_screenshots = list(set(all_screenshots)) + + if tracker == "covers": + multi_screens = len(all_screenshots) + + # If we still don't have enough screenshots, generate new ones + if len(all_screenshots) < multi_screens: + # Calculate how many more screenshots we need + needed_screenshots = multi_screens - len(all_screenshots) + + if meta.get('debug'): + console.print(f"[yellow]Found {len(all_screenshots)} screenshots, need {needed_screenshots} more to reach {multi_screens} total.") + + try: + if meta['is_disc'] == "BDMV": + await disc_screenshots(meta, filename, meta['bdinfo'], folder_id, base_dir, + meta.get('vapoursynth', False), [], meta.get('ffdebug', False), + needed_screenshots, True) + elif meta['is_disc'] == "DVD": + await dvd_screenshots(meta, 0, None, True) + else: + await screenshots(path, filename, meta['uuid'], base_dir, meta, + needed_screenshots, True, None) + + if meta['is_disc'] == "DVD": + new_screens = await asyncio.to_thread(glob.glob, f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['discs'][0]['name']}-*.png") + else: + # Use a more generic pattern to find any PNG files that aren't already in all_screenshots + new_screens = await asyncio.to_thread(glob.glob, os.path.join(screenshots_dir, "*.png")) + + # Filter out files we already have + new_screens = [screen for screen in new_screens if screen not in all_screenshots] + + # Add any new screenshots to our list (only those not already in all_screenshots) + if new_screens and meta.get('debug'): + console.print(f"[green]Found {len(new_screens)} new screenshots after generation") + + for screen in new_screens: + if screen not in all_screenshots: + all_screenshots.append(screen) + if meta.get('debug'): + console.print(f"[green]Added new screenshot: {os.path.basename(screen)}") + + except Exception as e: + console.print(f"[red]Error during screenshot capture: {e}") + import traceback + console.print(f"[dim]{traceback.format_exc()}[/dim]") + + if not all_screenshots: + console.print("[red]No screenshots were generated or found. Please check the screenshot generation process.") + return [], True, images_reuploaded + + all_screenshots.sort() + existing_from_image_list = [] + other_screenshots = [] + + # First separate the screenshots into two categories + for screenshot in all_screenshots: + basename = os.path.basename(screenshot) + # Check if this is from the image_list we extracted earlier + if meta.get('image_list') and any(os.path.basename(urlparse(img.get('raw_url', '')).path) == basename + for img in meta['image_list']): + existing_from_image_list.append(screenshot) + else: + other_screenshots.append(screenshot) + + # First take all existing screenshots from image_list + final_screenshots = existing_from_image_list.copy() + + # Then fill up to multi_screens with other screenshots + remaining_needed = multi_screens - len(final_screenshots) + if remaining_needed > 0 and other_screenshots: + final_screenshots.extend(other_screenshots[:remaining_needed]) + + # If we still don't have enough, just use whatever we have + if len(final_screenshots) < multi_screens and len(all_screenshots) >= multi_screens: + # Fill with any remaining screenshots not yet included + remaining = [s for s in all_screenshots if s not in final_screenshots] + final_screenshots.extend(remaining[:multi_screens - len(final_screenshots)]) + + if tracker == "covers": + all_screenshots = all_screenshots + else: + all_screenshots = final_screenshots[:multi_screens] + + if meta.get('debug'): + console.print(f"[green]Using {len(all_screenshots)} screenshots:") + for i, screenshot in enumerate(all_screenshots): + console.print(f" {i+1}. {os.path.basename(screenshot)}") + + if not meta.get('skip_imghost_upload', False): + uploaded_images = [] + + # Add a max retry limit to prevent infinite loop + max_retries = len(approved_image_hosts) + while img_host_index <= max_retries: + current_img_host_key = f'img_host_{img_host_index}' + current_img_host = config.get('DEFAULT', {}).get(current_img_host_key) + + if not current_img_host: + console.print("[red]No more image hosts left to try.") + return [], True, images_reuploaded + + if current_img_host not in approved_image_hosts: + console.print(f"[red]Your preferred image host '{current_img_host}' is not supported at {tracker}, trying next host.") + retry_mode = True + images_reuploaded = True + img_host_index += 1 + continue + else: + meta['imghost'] = current_img_host + if meta['debug']: + console.print(f"[green]Uploading to approved host '{current_img_host}'.") + break + + uploaded_images, _ = await upload_screens( + meta, multi_screens, img_host_index, 0, multi_screens, + all_screenshots, {new_images_key: meta[new_images_key]}, retry_mode + ) + if uploaded_images: + meta[new_images_key] = uploaded_images + + if meta['debug']: + console.print(f"[debug] Updated {new_images_key} with {len(uploaded_images)} images.") + for image in uploaded_images: + console.print(f"[debug] Response in upload_image_task: {image['img_url']}, {image['raw_url']}, {image['web_url']}") + + for image in meta.get(new_images_key, []): + raw_url = image['raw_url'] + parsed_url = urlparse(raw_url) + hostname = parsed_url.netloc + mapped_host = await match_host(hostname, url_host_mapping.keys()) + mapped_host = url_host_mapping.get(mapped_host, mapped_host) + + if mapped_host not in approved_image_hosts: + console.print(f"[red]Unsupported image host detected in URL '{raw_url}'. Please use one of the approved image hosts.") + if original_imghost: + meta['imghost'] = original_imghost + return meta[new_images_key], True, images_reuploaded # Trigger retry_mode if switching hosts + + # Ensure all uploaded images are valid + valid_hosts = [] + for image in meta[new_images_key]: + netloc = urlparse(image['raw_url']).netloc + matched_host = await match_host(netloc, url_host_mapping.keys()) + mapped_host = url_host_mapping.get(matched_host, matched_host) + valid_hosts.append(mapped_host in approved_image_hosts) + if all(valid_hosts) and new_images_key in meta and isinstance(meta[new_images_key], list): + if tracker == "covers": + output_file = os.path.join(meta['base_dir'], 'tmp', meta['uuid'], "covers.json") + else: + output_file = os.path.join(screenshots_dir, "reuploaded_images.json") + + try: + async with aiofiles.open(output_file, 'r', encoding='utf-8') as f: + existing_data = await f.read() + existing_data = json.loads(existing_data) if existing_data else [] + if not isinstance(existing_data, list): + console.print(f"[red]Existing data in {output_file} is not a list. Resetting to an empty list.") + existing_data = [] + except Exception: + existing_data = [] + + updated_data = existing_data + meta[new_images_key] + updated_data = [dict(s) for s in {tuple(d.items()) for d in updated_data}] + + if tracker == "covers" and "release_url" in meta: + for image in updated_data: + if "release_url" not in image: + image["release_url"] = meta["release_url"] + console.print(f"[green]Added release URL to {len(updated_data)} cover images: {meta['release_url']}") + + try: + async with aiofiles.open(output_file, 'w', encoding='utf-8') as f: + await f.write(json.dumps(updated_data, indent=4)) + if meta['debug']: + console.print(f"[green]Successfully updated reuploaded images in {output_file}.") + + if tracker == "covers": + deleted_count = 0 + for screenshot in all_screenshots: + try: + if os.path.exists(screenshot): + os.remove(screenshot) + deleted_count += 1 + if meta.get('debug'): + console.print(f"[dim]Deleted cover image file: {screenshot}[/dim]") + except Exception as e: + console.print(f"[yellow]Failed to delete cover image file {screenshot}: {str(e)}[/yellow]") + + if deleted_count > 0: + if meta['debug']: + console.print(f"[green]Cleaned up {deleted_count} cover image files after successful upload[/green]") + + except Exception as e: + console.print(f"[red]Failed to save reuploaded images: {e}") + else: + console.print("[red]new_images_key is not a valid key in meta or is not a list.") + + if original_imghost: + meta['imghost'] = original_imghost + return meta[new_images_key], False, images_reuploaded + else: + if original_imghost: + meta['imghost'] = original_imghost + return meta[new_images_key], False, images_reuploaded diff --git a/src/search.py b/src/search.py index 8e782ee7e..29d96b618 100644 --- a/src/search.py +++ b/src/search.py @@ -1,26 +1,27 @@ import platform -import asyncio import os from src.console import console + class Search(): """ Logic for searching """ + def __init__(self, config): self.config = config pass - async def searchFile(self, filename): - os_info = platform.platform() + os_info = platform.platform() # noqa F841 filename = filename.lower() files_total = [] if filename == "": console.print("nothing entered") return - file_found = False + file_found = False # noqa F841 words = filename.split() + async def search_file(search_dir): files_total_search = [] console.print(f"Searching {search_dir}") @@ -30,11 +31,11 @@ async def search_file(search_dir): l_name = name.lower() os_info = platform.platform() if await self.file_search(l_name, words): - file_found = True - if('Windows' in os_info): - files_total_search.append(root+'\\'+name) + file_found = True # noqa F841 + if ('Windows' in os_info): + files_total_search.append(root + '\\' + name) else: - files_total_search.append(root+'/'+name) + files_total_search.append(root + '/' + name) return files_total_search config_dir = self.config['DISCORD']['search_dir'] if isinstance(config_dir, list): @@ -46,14 +47,15 @@ async def search_file(search_dir): return files_total async def searchFolder(self, foldername): - os_info = platform.platform() + os_info = platform.platform() # noqa F841 foldername = foldername.lower() folders_total = [] if foldername == "": console.print("nothing entered") return - folders_found = False + folders_found = False # noqa F841 words = foldername.split() + async def search_dir(search_dir): console.print(f"Searching {search_dir}") folders_total_search = [] @@ -65,28 +67,28 @@ async def search_dir(search_dir): os_info = platform.platform() if await self.file_search(l_name, words): - folder_found = True - if('Windows' in os_info): - folders_total_search.append(root+'\\'+name) + folder_found = True # noqa F841 + if ('Windows' in os_info): + folders_total_search.append(root + '\\' + name) else: - folders_total_search.append(root+'/'+name) - + folders_total_search.append(root + '/' + name) + return folders_total_search config_dir = self.config['DISCORD']['search_dir'] if isinstance(config_dir, list): for each in config_dir: folders = await search_dir(each) - + folders_total = folders_total + folders else: folders_total = await search_dir(config_dir) - return folders_total return folders_total + async def file_search(self, name, name_words): check = True for word in name_words: if word not in name: check = False break - return check \ No newline at end of file + return check diff --git a/src/sonarr.py b/src/sonarr.py new file mode 100644 index 000000000..09a75c2bb --- /dev/null +++ b/src/sonarr.py @@ -0,0 +1,139 @@ +import httpx +from data.config import config +from src.console import console + + +async def get_sonarr_data(tvdb_id=None, filename=None, title=None, debug=False): + if not any(key.startswith('sonarr_api_key') for key in config['DEFAULT']): + console.print("[red]No Sonarr API keys are configured.[/red]") + return None + + # Try each Sonarr instance until we get valid data + instance_index = 0 + max_instances = 4 # Limit to prevent infinite loops + + while instance_index < max_instances: + # Determine the suffix for this instance + suffix = "" if instance_index == 0 else f"_{instance_index}" + api_key_name = f"sonarr_api_key{suffix}" + url_name = f"sonarr_url{suffix}" + + # Check if this instance exists in config + if api_key_name not in config['DEFAULT'] or not config['DEFAULT'][api_key_name]: + # No more instances to try + break + + # Get instance-specific configuration + api_key = config['DEFAULT'][api_key_name].strip() + base_url = config['DEFAULT'][url_name].strip() + + if debug: + console.print(f"[blue]Trying Sonarr instance {instance_index if instance_index > 0 else 'default'}[/blue]") + + # Build the appropriate URL + if tvdb_id: + url = f"{base_url}/api/v3/series?tvdbId={tvdb_id}&includeSeasonImages=false" + elif filename and title: + url = f"{base_url}/api/v3/parse?title={title}&path={filename}" + else: + instance_index += 1 + continue + + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + if debug: + console.print(f"[green]TVDB ID {tvdb_id}[/green]") + console.print(f"[blue]Sonarr URL:[/blue] {url}") + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=10.0) + + if response.status_code == 200: + data = response.json() + + if debug: + console.print(f"[blue]Sonarr Response Status:[/blue] {response.status_code}") + console.print(f"[blue]Sonarr Response Data:[/blue] {data}") + + # Check if we got valid data by trying to extract show info + show_data = await extract_show_data(data) + + if show_data and (show_data.get("tvdb_id") or show_data.get("imdb_id") or show_data.get("tmdb_id")): + console.print(f"[green]Found valid show data from Sonarr instance {instance_index if instance_index > 0 else 'default'}[/green]") + return show_data + else: + console.print(f"[yellow]Failed to fetch from Sonarr instance {instance_index if instance_index > 0 else 'default'}: {response.status_code} - {response.text}[/yellow]") + + except httpx.RequestError as e: + console.print(f"[red]Error fetching from Sonarr instance {instance_index if instance_index > 0 else 'default'}: {e}[/red]") + except httpx.TimeoutException: + console.print(f"[red]Timeout when fetching from Sonarr instance {instance_index if instance_index > 0 else 'default'}[/red]") + except Exception as e: + console.print(f"[red]Unexpected error with Sonarr instance {instance_index if instance_index > 0 else 'default'}: {e}[/red]") + + # Move to the next instance + instance_index += 1 + + # If we got here, no instances provided valid data + console.print("[yellow]No Sonarr instance returned valid show data.[/yellow]") + return None + + +async def extract_show_data(sonarr_data): + if not sonarr_data: + return { + "tvdb_id": None, + "imdb_id": None, + "tvmaze_id": None, + "tmdb_id": None, + "genres": [], + "title": "", + "year": None, + "release_group": None + } + + # Handle response from /api/v3/parse endpoint + if isinstance(sonarr_data, dict) and 'series' in sonarr_data: + series = sonarr_data['series'] + release_group = sonarr_data.get('parsedEpisodeInfo', {}).get('releaseGroup') + + return { + "tvdb_id": series.get("tvdbId", None), + "imdb_id": int(series.get("imdbId", "tt0").replace("tt", "")) if series.get("imdbId") else None, + "tvmaze_id": series.get("tvMazeId", None), + "tmdb_id": series.get("tmdbId", None), + "genres": series.get("genres", []), + "release_group": release_group if release_group else None, + "year": series.get("year", None) + } + + # Handle response from /api/v3/series endpoint (list format) + elif isinstance(sonarr_data, list) and len(sonarr_data) > 0: + series = sonarr_data[0] + + return { + "tvdb_id": series.get("tvdbId", None), + "imdb_id": int(series.get("imdbId", "tt0").replace("tt", "")) if series.get("imdbId") else None, + "tvmaze_id": series.get("tvMazeId", None), + "tmdb_id": series.get("tmdbId", None), + "genres": series.get("genres", []), + "title": series.get("title", ""), + "year": series.get("year", None), + "release_group": series.get("releaseGroup") if series.get("releaseGroup") else None + } + + # Return empty data if the format doesn't match any expected structure + return { + "tvdb_id": None, + "imdb_id": None, + "tvmaze_id": None, + "tmdb_id": None, + "genres": [], + "title": "", + "year": None, + "release_group": None + } diff --git a/src/tags.py b/src/tags.py new file mode 100644 index 000000000..842ea11ba --- /dev/null +++ b/src/tags.py @@ -0,0 +1,103 @@ +import os +import re +import json +from guessit import guessit +from src.console import console + + +async def get_tag(video, meta): + # Using regex from cross-seed (https://github.com/cross-seed/cross-seed/tree/master?tab=Apache-2.0-1-ov-file) + release_group = None + basename = os.path.basename(video) + + # Try specialized regex patterns first + if meta.get('anime', False): + # Anime pattern: [Group] at the beginning + basename_stripped = os.path.splitext(basename)[0] + anime_match = re.search(r'^\s*\[(.+?)\]', basename_stripped) + if anime_match: + release_group = anime_match.group(1) + if meta['debug']: + console.print(f"Anime regex match: {release_group}") + else: + if not meta.get('is_disc') == "BDMV": + # Non-anime pattern: group at the end after last hyphen, avoiding resolutions and numbers + if os.path.isdir(video): + # If video is a directory, use the directory name as basename + basename_stripped = os.path.basename(os.path.normpath(video)) + else: + # If video is a file, use the filename without extension + basename_stripped = os.path.splitext(os.path.basename(video))[0] + non_anime_match = re.search(r'(?<=-)((?!\s*(?:WEB-DL|Blu-ray|H-264|H-265))(?:\W|\b)(?!(?:\d{3,4}[ip]))(?!\d+\b)(?:\W|\b)([\w .]+?))(?:\[.+\])?(?:\))?(?:\s\[.+\])?$', basename_stripped) + if non_anime_match: + release_group = non_anime_match.group(1).strip() + if "Z0N3" in release_group: + release_group = release_group.replace("Z0N3", "D-Z0N3") + if not meta.get('scene', False): + if release_group and len(release_group) > 12: + release_group = None + if meta['debug']: + console.print(f"Non-anime regex match: {release_group}") + + # If regex patterns didn't work, fall back to guessit + if not release_group: + try: + parsed = guessit(video) + release_group = parsed.get('release_group') + if meta['debug']: + console.print(f"Guessit match: {release_group}") + + except Exception as e: + console.print(f"Error while parsing group tag: {e}") + release_group = None + + # BDMV validation + if meta['is_disc'] == "BDMV" and release_group: + if f"{release_group}" not in video: + release_group = None + + # Format the tag + tag = f"-{release_group}" if release_group else "" + + # Clean up any tags that are just a hyphen + if tag == "-": + tag = "" + + # Remove generic "no group" tags + if tag and tag[1:].lower() in ["hd.ma.5.1", "untouched"]: + tag = "" + + return tag + + +async def tag_override(meta): + try: + with open(f"{meta['base_dir']}/data/tags.json", 'r', encoding="utf-8") as f: + tags = json.load(f) + f.close() + + for tag in tags: + value = tags.get(tag) + if value.get('in_name', "") == tag and tag in meta['path']: + meta['tag'] = f"-{tag}" + if meta['tag'][1:] == tag: + for key in value: + if key == 'type': + if meta[key] == "ENCODE": + meta[key] = value.get(key) + else: + pass + elif key == 'personalrelease': + meta[key] = _is_true(value.get(key, "False")) + elif key == 'template': + meta['desc_template'] = value.get(key) + else: + meta[key] = value.get(key) + except Exception as e: + console.print(f"Error while loading tags.json: {e}") + return meta + return meta + + +def _is_true(value): + return str(value).strip().lower() == "true" diff --git a/src/takescreens.py b/src/takescreens.py new file mode 100644 index 000000000..937955977 --- /dev/null +++ b/src/takescreens.py @@ -0,0 +1,1945 @@ +import os +import re +import glob +import time +import ffmpeg +import random +import json +import platform +import asyncio +import oxipng +import psutil +import sys +import concurrent.futures +import signal +import gc +import traceback +from pymediainfo import MediaInfo +from src.console import console +from data.config import config +from src.cleanup import cleanup, reset_terminal + +task_limit = int(config['DEFAULT'].get('process_limit', 1)) +threads = str(config['DEFAULT'].get('threads', '1')) +cutoff = int(config['DEFAULT'].get('cutoff_screens', 1)) +ffmpeg_limit = config['DEFAULT'].get('ffmpeg_limit', False) +ffmpeg_is_good = config['DEFAULT'].get('ffmpeg_is_good', False) +use_libplacebo = config['DEFAULT'].get('use_libplacebo', True) + +try: + task_limit = int(task_limit) # Convert to integer +except ValueError: + task_limit = 1 +tone_map = config['DEFAULT'].get('tone_map', False) +optimize_images = config['DEFAULT'].get('optimize_images', True) +algorithm = config['DEFAULT'].get('algorithm', 'mobius').strip() +desat = float(config['DEFAULT'].get('desat', 10.0)) + + +async def run_ffmpeg(command): + if platform.system() == 'Linux': + ffmpeg_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'bin', 'ffmpeg', 'ffmpeg') + if os.path.exists(ffmpeg_path): + cmd_list = command.compile() + cmd_list[0] = ffmpeg_path + + process = await asyncio.create_subprocess_exec( + *cmd_list, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + return process.returncode, stdout, stderr + + process = await asyncio.create_subprocess_exec( + *command.compile(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + return process.returncode, stdout, stderr + + +async def sanitize_filename(filename): + # Replace invalid characters like colons with an underscore + return re.sub(r'[<>:"/\\|?*]', '_', filename) + + +async def disc_screenshots(meta, filename, bdinfo, folder_id, base_dir, use_vs, image_list, ffdebug, num_screens=None, force_screenshots=False): + img_host = await get_image_host(meta) + screens = meta['screens'] + if meta['debug']: + start_time = time.time() + if 'image_list' not in meta: + meta['image_list'] = [] + existing_images = [img for img in meta['image_list'] if isinstance(img, dict) and img.get('img_url', '').startswith('http')] + + if len(existing_images) >= cutoff and not force_screenshots: + console.print(f"[yellow]There are already at least {cutoff} images in the image list. Skipping additional screenshots.") + return + + if num_screens is None: + num_screens = screens + if num_screens == 0 or len(image_list) >= num_screens: + return + + sanitized_filename = await sanitize_filename(filename) + length = 0 + file = None + frame_rate = None + for each in bdinfo['files']: + # Calculate total length in seconds, including fractional part + int_length = sum(float(x) * 60 ** i for i, x in enumerate(reversed(each['length'].split(':')))) + + if int_length > length: + length = int_length + for root, dirs, files in os.walk(bdinfo['path']): + for name in files: + if name.lower() == each['file'].lower(): + file = os.path.join(root, name) + break # Stop searching once the file is found + + if 'video' in bdinfo and bdinfo['video']: + fps_string = bdinfo['video'][0].get('fps', None) + if fps_string: + try: + frame_rate = float(fps_string.split(' ')[0]) # Extract and convert to float + except ValueError: + console.print("[red]Error: Unable to parse frame rate from bdinfo['video'][0]['fps']") + + keyframe = 'nokey' if "VC-1" in bdinfo['video'][0]['codec'] or bdinfo['video'][0]['hdr_dv'] != "" else 'none' + if meta['debug']: + print(f"File: {file}, Length: {length}, Frame Rate: {frame_rate}") + os.chdir(f"{base_dir}/tmp/{folder_id}") + existing_screens = glob.glob(f"{sanitized_filename}-*.png") + total_existing = len(existing_screens) + len(existing_images) + if not force_screenshots: + num_screens = max(0, screens - total_existing) + else: + num_screens = num_screens + + if num_screens == 0 and not force_screenshots: + console.print('[bold green]Reusing existing screenshots. No additional screenshots needed.') + return + + if meta['debug'] and not force_screenshots: + console.print(f"[bold yellow]Saving Screens... Total needed: {screens}, Existing: {total_existing}, To capture: {num_screens}") + + if tone_map and "HDR" in meta['hdr']: + hdr_tonemap = True + meta['tonemapped'] = True + else: + hdr_tonemap = False + + ss_times = await valid_ss_time([], num_screens, length, frame_rate, meta, retake=force_screenshots) + + if meta.get('frame_overlay', False): + console.print("[yellow]Getting frame information for overlays...") + frame_info_tasks = [ + get_frame_info(file, ss_times[i], meta) + for i in range(num_screens + 1) + if not os.path.exists(f"{base_dir}/tmp/{folder_id}/{sanitized_filename}-{i}.png") + or meta.get('retake', False) + ] + frame_info_results = await asyncio.gather(*frame_info_tasks) + meta['frame_info_map'] = {} + + # Create a mapping from time to frame info + for i, info in enumerate(frame_info_results): + meta['frame_info_map'][ss_times[i]] = info + + if meta['debug']: + console.print(f"[cyan]Collected frame information for {len(frame_info_results)} frames") + + capture_tasks = [] + capture_results = [] + if use_vs: + from src.vs import vs_screengn + vs_screengn(source=file, encode=None, filter_b_frames=False, num=num_screens, dir=f"{base_dir}/tmp/{folder_id}/") + else: + if meta.get('ffdebug', False): + loglevel = 'verbose' + else: + loglevel = 'quiet' + + existing_indices = {int(p.split('-')[-1].split('.')[0]) for p in existing_screens} + capture_tasks = [ + capture_disc_task( + i, + file, + ss_times[i], + os.path.abspath(f"{base_dir}/tmp/{folder_id}/{sanitized_filename}-{len(existing_indices) + i}.png"), + keyframe, + loglevel, + hdr_tonemap, + meta + ) + for i in range(num_screens + 1) + ] + + results = await asyncio.gather(*capture_tasks) + filtered_results = [r for r in results if isinstance(r, tuple) and len(r) == 2] + + if len(filtered_results) != len(results): + console.print(f"[yellow]Warning: {len(results) - len(filtered_results)} capture tasks returned invalid results.") + + filtered_results.sort(key=lambda x: x[0]) # Ensure order is preserved + capture_results = [r[1] for r in filtered_results if r[1] is not None] + + if capture_results and len(capture_results) > num_screens: + try: + smallest = min(capture_results, key=os.path.getsize) + if meta['debug']: + console.print(f"[yellow]Removing smallest image: {smallest} ({os.path.getsize(smallest)} bytes)") + os.remove(smallest) + capture_results.remove(smallest) + except Exception as e: + console.print(f"[red]Error removing smallest image: {str(e)}") + + if not force_screenshots and meta['debug']: + console.print(f"[green]Successfully captured {len(capture_results)} screenshots.") + + optimized_results = [] + valid_images = [image for image in capture_results if os.path.exists(image)] + + if not valid_images: + console.print("[red]No valid images found for optimization.[/red]") + return [] + + # Dynamically determine the number of processes + num_tasks = len(valid_images) + num_workers = min(num_tasks, task_limit) + if optimize_images: + if meta['debug']: + console.print("[yellow]Now optimizing images...[/yellow]") + + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + def handle_sigint(sig, frame): + console.print("\n[red]CTRL+C detected. Cancelling optimization...[/red]") + executor.shutdown(wait=False) + stop_event.set() + for task in asyncio.all_tasks(loop): + task.cancel() + + signal.signal(signal.SIGINT, handle_sigint) + + try: + with concurrent.futures.ProcessPoolExecutor(max_workers=num_workers) as executor: + tasks = [asyncio.create_task(worker_wrapper(image, optimize_image_task, executor)) for image in valid_images] + + optimized_results = await asyncio.gather(*tasks, return_exceptions=True) + + except KeyboardInterrupt: + console.print("\n[red]CTRL+C detected. Cancelling tasks...[/red]") + executor.shutdown(wait=False) + await kill_all_child_processes() + console.print("[red]All tasks cancelled. Exiting.[/red]") + sys.exit(1) + + finally: + if meta['debug']: + console.print("[yellow]Shutting down optimization workers...[/yellow]") + executor.shutdown(wait=False) + await asyncio.sleep(0.1) + await kill_all_child_processes() + gc.collect() + + optimized_results = [res for res in optimized_results if not isinstance(res, str) or not res.startswith("Error")] + if meta['debug']: + console.print("Optimized results:", optimized_results) + + if not force_screenshots and meta['debug']: + console.print(f"[green]Successfully optimized {len(optimized_results)} images.[/green]") + else: + optimized_results = valid_images + + valid_results = [] + remaining_retakes = [] + for image_path in optimized_results: + if "Error" in image_path: + console.print(f"[red]{image_path}") + continue + + retake = False + image_size = os.path.getsize(image_path) + if meta['debug']: + console.print(f"[yellow]Checking image {image_path} (size: {image_size} bytes) for image host: {img_host}[/yellow]") + if image_size <= 75000: + console.print(f"[yellow]Image {image_path} is incredibly small, retaking.") + retake = True + else: + if "imgbb" in img_host: + if image_size <= 31000000: + if meta['debug']: + console.print(f"[green]Image {image_path} meets size requirements for imgbb.[/green]") + else: + console.print(f"[red]Image {image_path} with size {image_size} bytes: does not meet size requirements for imgbb, retaking.") + retake = True + elif img_host in ["imgbox", "pixhost"]: + if 75000 < image_size <= 10000000: + if meta['debug']: + console.print(f"[green]Image {image_path} meets size requirements for {img_host}.[/green]") + else: + console.print(f"[red]Image {image_path} with size {image_size} bytes: does not meet size requirements for {img_host}, retaking.") + retake = True + elif img_host in ["ptpimg", "lensdump", "ptscreens", "onlyimage", "dalexni", "zipline", "passtheimage"]: + if meta['debug']: + console.print(f"[green]Image {image_path} meets size requirements for {img_host}.[/green]") + else: + console.print(f"[red]Unknown image host or image doesn't meet requirements for host: {img_host}, retaking.") + retake = True + + if retake: + retry_attempts = 3 + for attempt in range(1, retry_attempts + 1): + console.print(f"[yellow]Retaking screenshot for: {image_path} (Attempt {attempt}/{retry_attempts})[/yellow]") + try: + index = int(image_path.rsplit('-', 1)[-1].split('.')[0]) + if os.path.exists(image_path): + os.remove(image_path) + + random_time = random.uniform(0, length) + screenshot_response = await capture_disc_task( + index, file, random_time, image_path, keyframe, loglevel, hdr_tonemap, meta + ) + if optimize_images: + optimize_image_task(screenshot_response) + new_size = os.path.getsize(screenshot_response) + valid_image = False + + if "imgbb" in img_host: + if new_size > 75000 and new_size <= 31000000: + console.print(f"[green]Successfully retaken screenshot for: {screenshot_response} ({new_size} bytes)[/green]") + valid_image = True + elif img_host in ["imgbox", "pixhost"]: + if new_size > 75000 and new_size <= 10000000: + console.print(f"[green]Successfully retaken screenshot for: {screenshot_response} ({new_size} bytes)[/green]") + valid_image = True + elif img_host in ["ptpimg", "lensdump", "ptscreens", "onlyimage", "dalexni", "zipline", "passtheimage"]: + if new_size > 75000: + console.print(f"[green]Successfully retaken screenshot for: {screenshot_response} ({new_size} bytes)[/green]") + valid_image = True + + if valid_image: + valid_results.append(screenshot_response) + break + else: + console.print(f"[red]Retaken image {screenshot_response} does not meet the size requirements for {img_host}. Retrying...[/red]") + except Exception as e: + console.print(f"[red]Error retaking screenshot for {image_path}: {e}[/red]") + else: + console.print(f"[red]All retry attempts failed for {image_path}. Skipping.[/red]") + remaining_retakes.append(image_path) + else: + valid_results.append(image_path) + + if remaining_retakes: + console.print(f"[red]The following images could not be retaken successfully: {remaining_retakes}[/red]") + + if not force_screenshots and meta['debug']: + console.print(f"[green]Successfully captured {len(valid_results)} screenshots.") + + if meta['debug']: + finish_time = time.time() + console.print(f"Screenshots processed in {finish_time - start_time:.4f} seconds") + + multi_screens = int(config['DEFAULT'].get('multiScreens', 2)) + discs = meta.get('discs', []) + one_disc = True + if discs and len(discs) == 1: + one_disc = True + elif discs and len(discs) > 1: + one_disc = False + + if (not meta.get('tv_pack') and one_disc) or multi_screens == 0: + await cleanup() + + +async def capture_disc_task(index, file, ss_time, image_path, keyframe, loglevel, hdr_tonemap, meta): + try: + ff = ffmpeg.input(file, ss=ss_time, skip_frame=keyframe) + if hdr_tonemap: + ff = ( + ff + .filter('zscale', transfer='linear') + .filter('tonemap', tonemap=algorithm, desat=desat) + .filter('zscale', transfer='bt709') + .filter('format', 'rgb24') + ) + + if meta.get('frame_overlay', False): + # Get frame info from pre-collected data if available + frame_info = meta.get('frame_info_map', {}).get(ss_time, {}) + + frame_rate = meta.get('frame_rate', 24.0) + frame_number = int(ss_time * frame_rate) + + # If we have PTS time from frame info, use it to calculate a more accurate frame number + if 'pts_time' in frame_info: + # Only use PTS time for frame number calculation if it makes sense + # (sometimes seeking can give us a frame from the beginning instead of where we want) + pts_time = frame_info.get('pts_time', 0) + if pts_time > 1.0 and abs(pts_time - ss_time) < 10: + frame_number = int(pts_time * frame_rate) + + frame_type = frame_info.get('frame_type', 'Unknown') + + text_size = int(config['DEFAULT'].get('overlay_text_size', 18)) + # Get the resolution and convert it to integer + resol = int(''.join(filter(str.isdigit, meta.get('resolution', '1080p')))) + font_size = round(text_size*resol/1080) + x_all = round(10*resol/1080) + + # Scale vertical spacing based on font size + line_spacing = round(font_size * 1.1) + y_number = x_all + y_type = y_number + line_spacing + y_hdr = y_type + line_spacing + + # Use the filtered output with frame info + base_text = ff + + # Frame number + base_text = base_text.filter('drawtext', + text=f"Frame Number: {frame_number}", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_number, + box=1, + boxcolor='black@0.5' + ) + + # Frame type + base_text = base_text.filter('drawtext', + text=f"Frame Type: {frame_type}", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_type, + box=1, + boxcolor='black@0.5' + ) + + # HDR status + if hdr_tonemap: + base_text = base_text.filter('drawtext', + text="Tonemapped HDR", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_hdr, + box=1, + boxcolor='black@0.5' + ) + + # Use the filtered output with frame info + ff = base_text + + command = ( + ff + .output(image_path, vframes=1, pix_fmt="rgb24") + .overwrite_output() + .global_args('-loglevel', loglevel) + ) + + returncode, stdout, stderr = await run_ffmpeg(command) + if returncode == 0: + return (index, image_path) + else: + console.print(f"[red]FFmpeg error capturing screenshot: {stderr.decode()}") + return (index, None) # Ensure tuple format + except Exception as e: + console.print(f"[red]Error capturing screenshot: {e}") + return None + + +async def dvd_screenshots(meta, disc_num, num_screens=None, retry_cap=None): + screens = meta['screens'] + if 'image_list' not in meta: + meta['image_list'] = [] + existing_images = [img for img in meta['image_list'] if isinstance(img, dict) and img.get('img_url', '').startswith('http')] + + if len(existing_images) >= cutoff and not retry_cap: + console.print(f"[yellow]There are already at least {cutoff} images in the image list. Skipping additional screenshots.") + return + screens = meta.get('screens', 6) + if num_screens is None: + num_screens = screens - len(existing_images) + if num_screens == 0 or (len(meta.get('image_list', [])) >= screens and disc_num == 0): + return + + if len(glob.glob(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['discs'][disc_num]['name']}-*.png")) >= num_screens: + i = num_screens + console.print('[bold green]Reusing screenshots') + return + + ifo_mi = MediaInfo.parse(f"{meta['discs'][disc_num]['path']}/VTS_{meta['discs'][disc_num]['main_set'][0][:2]}_0.IFO", mediainfo_options={'inform_version': '1'}) + sar = 1 + for track in ifo_mi.tracks: + if track.track_type == "Video": + if isinstance(track.duration, str): + durations = [float(d) for d in track.duration.split(' / ')] + length = max(durations) / 1000 # Use the longest duration + else: + length = float(track.duration) / 1000 # noqa #F841 # Convert to seconds + + par = float(track.pixel_aspect_ratio) + dar = float(track.display_aspect_ratio) + width = float(track.width) + height = float(track.height) + frame_rate = float(track.frame_rate) + if par < 1: + new_height = dar * height + sar = width / new_height + w_sar = 1 + h_sar = sar + else: + sar = par + w_sar = sar + h_sar = 1 + + async def _is_vob_good(n, loops, num_screens): + max_loops = 6 + fallback_duration = 300 + valid_tracks = [] + + while loops < max_loops: + try: + vob_mi = MediaInfo.parse( + f"{meta['discs'][disc_num]['path']}/VTS_{main_set[n]}", + output='JSON' + ) + vob_mi = json.loads(vob_mi) + + for track in vob_mi.get('media', {}).get('track', []): + duration = float(track.get('Duration', 0)) + width = track.get('Width') + height = track.get('Height') + + if duration > 1 and width and height: # Minimum 1-second track + valid_tracks.append({ + 'duration': duration, + 'track_index': n + }) + + if valid_tracks: + # Sort by duration, take longest track + longest_track = max(valid_tracks, key=lambda x: x['duration']) + return longest_track['duration'], longest_track['track_index'] + + except Exception as e: + console.print(f"[red]Error parsing VOB {n}: {e}") + + n = (n + 1) % len(main_set) + loops += 1 + + return fallback_duration, 0 + + main_set = meta['discs'][disc_num]['main_set'][1:] if len(meta['discs'][disc_num]['main_set']) > 1 else meta['discs'][disc_num]['main_set'] + os.chdir(f"{meta['base_dir']}/tmp/{meta['uuid']}") + voblength, n = await _is_vob_good(0, 0, num_screens) + ss_times = await valid_ss_time([], num_screens, voblength, frame_rate, meta, retake=retry_cap) + capture_tasks = [] + existing_images = 0 + existing_image_paths = [] + + for i in range(num_screens + 1): + image = f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['discs'][disc_num]['name']}-{i}.png" + input_file = f"{meta['discs'][disc_num]['path']}/VTS_{main_set[i % len(main_set)]}" + if os.path.exists(image) and not meta.get('retake', False): + existing_images += 1 + existing_image_paths.append(image) + + if existing_images == num_screens and not meta.get('retake', False): + console.print("[yellow]The correct number of screenshots already exists. Skipping capture process.") + capture_results = existing_image_paths + return + else: + capture_tasks = [] + image_paths = [] + input_files = [] + + for i in range(num_screens + 1): + sanitized_disc_name = await sanitize_filename(meta['discs'][disc_num]['name']) + image = f"{meta['base_dir']}/tmp/{meta['uuid']}/{sanitized_disc_name}-{i}.png" + input_file = f"{meta['discs'][disc_num]['path']}/VTS_{main_set[i % len(main_set)]}" + image_paths.append(image) + input_files.append(input_file) + + if meta.get('frame_overlay', False): + if meta['debug']: + console.print("[yellow]Getting frame information for overlays...") + frame_info_tasks = [ + get_frame_info(input_files[i], ss_times[i], meta) + for i in range(num_screens + 1) + if not os.path.exists(image_paths[i]) or meta.get('retake', False) + ] + + frame_info_results = await asyncio.gather(*frame_info_tasks) + meta['frame_info_map'] = {} + + for i, info in enumerate(frame_info_results): + meta['frame_info_map'][ss_times[i]] = info + + if meta['debug']: + console.print(f"[cyan]Collected frame information for {len(frame_info_results)} frames") + + for i in range(num_screens + 1): + if not os.path.exists(image_paths[i]) or meta.get('retake', False): + capture_tasks.append( + capture_dvd_screenshot( + (i, input_files[i], image_paths[i], ss_times[i], meta, width, height, w_sar, h_sar) + ) + ) + + capture_results = [] + results = await asyncio.gather(*capture_tasks) + filtered_results = [r for r in results if isinstance(r, tuple) and len(r) == 2] + + if len(filtered_results) != len(results): + console.print(f"[yellow]Warning: {len(results) - len(filtered_results)} capture tasks returned invalid results.") + + filtered_results.sort(key=lambda x: x[0]) # Ensure order is preserved + capture_results = [r[1] for r in filtered_results if r[1] is not None] + + if capture_results and len(capture_results) > num_screens: + smallest = None + smallest_size = float('inf') + for screens in glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}/", f"{meta['discs'][disc_num]['name']}-*"): + screen_path = os.path.join(f"{meta['base_dir']}/tmp/{meta['uuid']}/", screens) + try: + screen_size = os.path.getsize(screen_path) + if screen_size < smallest_size: + smallest_size = screen_size + smallest = screen_path + except FileNotFoundError: + console.print(f"[red]File not found: {screen_path}[/red]") # Handle potential edge cases + continue + + if smallest: + if meta['debug']: + console.print(f"[yellow]Removing smallest image: {smallest} ({smallest_size} bytes)[/yellow]") + os.remove(smallest) + + optimized_results = [] + + # Filter out non-existent files first + valid_images = [image for image in capture_results if os.path.exists(image)] + + # Dynamically determine the number of processes + num_tasks = len(valid_images) + num_workers = min(num_tasks, task_limit) + + if optimize_images: + if num_workers == 0: + console.print("[red]No valid images found for optimization.[/red]") + return + if meta['debug']: + console.print("[yellow]Now optimizing images...[/yellow]") + + loop = asyncio.get_running_loop() + stop_event = asyncio.Event() + + def handle_sigint(sig, frame): + console.print("\n[red]CTRL+C detected. Cancelling optimization...[/red]") + executor.shutdown(wait=False) + stop_event.set() + for task in asyncio.all_tasks(loop): + task.cancel() + + signal.signal(signal.SIGINT, handle_sigint) + + try: + with concurrent.futures.ProcessPoolExecutor(max_workers=num_workers) as executor: + # Start all tasks in parallel using worker_wrapper() + tasks = [asyncio.create_task(worker_wrapper(image, optimize_image_task, executor)) for image in valid_images] + + # Wait for all tasks to complete + optimized_results = await asyncio.gather(*tasks, return_exceptions=True) + except KeyboardInterrupt: + console.print("\n[red]CTRL+C detected. Cancelling tasks...[/red]") + executor.shutdown(wait=False) + await kill_all_child_processes() + console.print("[red]All tasks cancelled. Exiting.[/red]") + sys.exit(1) + finally: + if meta['debug']: + console.print("[yellow]Shutting down optimization workers...[/yellow]") + await asyncio.sleep(0.1) + await kill_all_child_processes() + executor.shutdown(wait=False) + gc.collect() + + optimized_results = [res for res in optimized_results if not isinstance(res, str) or not res.startswith("Error")] + + if meta['debug']: + console.print("Optimized results:", optimized_results) + if not retry_cap and meta['debug']: + console.print(f"[green]Successfully optimized {len(optimized_results)} images.") + + executor.shutdown(wait=True) # Ensure cleanup + else: + optimized_results = valid_images + + valid_results = [] + remaining_retakes = [] + + for image in optimized_results: + if "Error" in image: + console.print(f"[red]{image}") + continue + + retake = False + image_size = os.path.getsize(image) + if image_size <= 120000: + console.print(f"[yellow]Image {image} is incredibly small, retaking.") + retake = True + + if retake: + retry_attempts = 3 + for attempt in range(1, retry_attempts + 1): + console.print(f"[yellow]Retaking screenshot for: {image} (Attempt {attempt}/{retry_attempts})[/yellow]") + + index = int(image.rsplit('-', 1)[-1].split('.')[0]) + input_file = f"{meta['discs'][disc_num]['path']}/VTS_{main_set[index % len(main_set)]}" + adjusted_time = random.uniform(0, voblength) + + if os.path.exists(image): # Prevent unnecessary deletion error + try: + os.remove(image) + except Exception as e: + console.print(f"[red]Failed to delete {image}: {e}[/red]") + break + + try: + # Ensure `capture_dvd_screenshot()` always returns a tuple + screenshot_response = await capture_dvd_screenshot( + (index, input_file, image, adjusted_time, meta, width, height, w_sar, h_sar) + ) + + # Ensure it is a tuple before unpacking + if not isinstance(screenshot_response, tuple) or len(screenshot_response) != 2: + console.print(f"[red]Failed to capture screenshot for {image}. Retrying...[/red]") + continue + + index, screenshot_result = screenshot_response # Safe unpacking + + if screenshot_result is None: + console.print(f"[red]Failed to capture screenshot for {image}. Retrying...[/red]") + continue + + if optimize_images: + optimize_image_task(screenshot_result) + + retaken_size = os.path.getsize(screenshot_result) + if retaken_size > 75000: + console.print(f"[green]Successfully retaken screenshot for: {screenshot_result} ({retaken_size} bytes)[/green]") + valid_results.append(screenshot_result) + break + else: + console.print(f"[red]Retaken image {screenshot_result} is still too small. Retrying...[/red]") + except Exception as e: + console.print(f"[red]Error capturing screenshot for {input_file} at {adjusted_time}: {e}[/red]") + + else: + console.print(f"[red]All retry attempts failed for {image}. Skipping.[/red]") + remaining_retakes.append(image) + else: + valid_results.append(image) + if remaining_retakes: + console.print(f"[red]The following images could not be retaken successfully: {remaining_retakes}[/red]") + + if not retry_cap and meta['debug']: + console.print(f"[green]Successfully captured {len(valid_results)} screenshots.") + + multi_screens = int(config['DEFAULT'].get('multiScreens', 2)) + discs = meta.get('discs', []) + one_disc = True + if discs and len(discs) == 1: + one_disc = True + elif discs and len(discs) > 1: + one_disc = False + + if (not meta.get('tv_pack') and one_disc) or multi_screens == 0: + await cleanup() + + +async def capture_dvd_screenshot(task): + index, input_file, image, seek_time, meta, width, height, w_sar, h_sar = task + + try: + loglevel = 'verbose' if meta.get('ffdebug', False) else 'quiet' + media_info = MediaInfo.parse(input_file) + video_duration = next((track.duration for track in media_info.tracks if track.track_type == "Video"), None) + + if video_duration and seek_time > video_duration: + seek_time = max(0, video_duration - 1) + + # Construct ffmpeg command + ff = ffmpeg.input(input_file, ss=seek_time) + if w_sar != 1 or h_sar != 1: + ff = ff.filter('scale', int(round(width * w_sar)), int(round(height * h_sar))) + + if meta.get('frame_overlay', False): + # Get frame info from pre-collected data if available + frame_info = meta.get('frame_info_map', {}).get(seek_time, {}) + + frame_rate = meta.get('frame_rate', 24.0) + frame_number = int(seek_time * frame_rate) + + # If we have PTS time from frame info, use it to calculate a more accurate frame number + if 'pts_time' in frame_info: + # Only use PTS time for frame number calculation if it makes sense + # (sometimes seeking can give us a frame from the beginning instead of where we want) + pts_time = frame_info.get('pts_time', 0) + if pts_time > 1.0 and abs(pts_time - seek_time) < 10: + frame_number = int(pts_time * frame_rate) + + frame_type = frame_info.get('frame_type', 'Unknown') + + text_size = int(config['DEFAULT'].get('overlay_text_size', 18)) + # Get the resolution and convert it to integer + resol = int(''.join(filter(str.isdigit, meta.get('resolution', '576p')))) + font_size = round(text_size*resol/576) + x_all = round(10*resol/576) + + # Scale vertical spacing based on font size + line_spacing = round(font_size * 1.1) + y_number = x_all + y_type = y_number + line_spacing + + # Use the filtered output with frame info + base_text = ff + + # Frame number + base_text = base_text.filter('drawtext', + text=f"Frame Number: {frame_number}", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_number, + box=1, + boxcolor='black@0.5' + ) + + # Frame type + base_text = base_text.filter('drawtext', + text=f"Frame Type: {frame_type}", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_type, + box=1, + boxcolor='black@0.5' + ) + + # Use the filtered output with frame info + ff = base_text + + returncode, _, stderr = await run_ffmpeg(ff.output(image, vframes=1, pix_fmt="rgb24").overwrite_output().global_args('-loglevel', loglevel, '-accurate_seek')) + if returncode != 0: + console.print(f"[red]Error capturing screenshot for {input_file} at {seek_time}s:[/red]\n{stderr.decode()}") + return (index, None) + + if os.path.exists(image): + return (index, image) + else: + console.print(f"[red]Screenshot creation failed for {image}[/red]") + return (index, None) + + except Exception as e: + console.print(f"[red]Error capturing screenshot for {input_file} at {seek_time}s: {e}[/red]") + return (index, None) + + +async def screenshots(path, filename, folder_id, base_dir, meta, num_screens=None, force_screenshots=False, manual_frames=None): + img_host = await get_image_host(meta) + screens = meta['screens'] + if meta['debug']: + start_time = time.time() + console.print("Image Host:", img_host) + if 'image_list' not in meta: + meta['image_list'] = [] + + existing_images = [img for img in meta['image_list'] if isinstance(img, dict) and img.get('img_url', '').startswith('http')] + + if len(existing_images) >= cutoff and not force_screenshots: + console.print(f"[yellow]There are already at least {cutoff} images in the image list. Skipping additional screenshots.") + return + + try: + with open(f"{base_dir}/tmp/{folder_id}/MediaInfo.json", encoding='utf-8') as f: + mi = json.load(f) + video_track = mi['media']['track'][1] + + def safe_float(value, default=0.0, field_name=""): + if isinstance(value, (int, float)): + return float(value) + elif isinstance(value, str): + try: + return float(value) + except ValueError: + console.print(f"[yellow]Warning: Could not convert string '{value}' to float for {field_name}, using default {default}[/yellow]") + return default + elif isinstance(value, dict): + for key in ['#value', 'value', 'duration', 'Duration']: + if key in value: + return safe_float(value[key], default, field_name) + console.print(f"[yellow]Warning: {field_name} is a dict but no usable value found: {value}, using default {default}[/yellow]") + return default + else: + console.print(f"[yellow]Warning: Unable to convert to float: {type(value)} {value} for {field_name}, using default {default}[/yellow]") + return default + + length = safe_float( + video_track.get('Duration'), + safe_float(mi['media']['track'][0].get('Duration'), 3600.0, "General Duration"), + "Video Duration" + ) + + width = safe_float(video_track.get('Width'), 1920.0, "Width") + height = safe_float(video_track.get('Height'), 1080.0, "Height") + par = safe_float(video_track.get('PixelAspectRatio'), 1.0, "PixelAspectRatio") + dar = safe_float(video_track.get('DisplayAspectRatio'), 16.0/9.0, "DisplayAspectRatio") + frame_rate = safe_float(video_track.get('FrameRate'), 24.0, "FrameRate") + + if par == 1: + sar = w_sar = h_sar = 1 + elif par < 1: + new_height = dar * height + sar = width / new_height + w_sar = 1 + h_sar = sar + else: + sar = w_sar = par + h_sar = 1 + except Exception as e: + console.print(f"[red]Error processing MediaInfo.json: {e}") + if meta.get('debug', False): + import traceback + console.print(traceback.format_exc()) + return + meta['frame_rate'] = frame_rate + loglevel = 'verbose' if meta.get('ffdebug', False) else 'quiet' + os.chdir(f"{base_dir}/tmp/{folder_id}") + + if manual_frames and meta['debug']: + console.print(f"[yellow]Using manual frames: {manual_frames}") + ss_times = [] + if manual_frames and not force_screenshots: + try: + if isinstance(manual_frames, str): + manual_frames_list = [int(frame.strip()) for frame in manual_frames.split(',') if frame.strip()] + elif isinstance(manual_frames, list): + manual_frames_list = [int(frame) if isinstance(frame, str) else frame for frame in manual_frames] + else: + manual_frames_list = [] + num_screens = len(manual_frames_list) + if num_screens > 0: + ss_times = [frame / frame_rate for frame in manual_frames_list] + except (TypeError, ValueError) as e: + if meta['debug'] and manual_frames: + console.print(f"[red]Error processing manual frames: {e}[/red]") + sys.exit(1) + + if num_screens is None or num_screens <= 0: + num_screens = screens - len(existing_images) + if num_screens <= 0: + return + + sanitized_filename = await sanitize_filename(filename) + + existing_images_count = 0 + existing_image_paths = [] + for i in range(num_screens): + image_path = os.path.abspath(f"{base_dir}/tmp/{folder_id}/{sanitized_filename}-{i}.png") + if os.path.exists(image_path) and not meta.get('retake', False): + existing_images_count += 1 + existing_image_paths.append(image_path) + + if existing_images_count == num_screens and not meta.get('retake', False): + console.print("[yellow]The correct number of screenshots already exists. Skipping capture process.") + return existing_image_paths + + num_capture = num_screens - existing_images_count + + if not ss_times: + ss_times = await valid_ss_time([], num_capture, length, frame_rate, meta, retake=force_screenshots) + + if meta.get('frame_overlay', False): + if meta['debug']: + console.print("[yellow]Getting frame information for overlays...") + frame_info_tasks = [ + get_frame_info(path, ss_times[i], meta) + for i in range(num_capture) + if not os.path.exists(f"{base_dir}/tmp/{folder_id}/{sanitized_filename}-{i}.png") + or meta.get('retake', False) + ] + frame_info_results = await asyncio.gather(*frame_info_tasks) + meta['frame_info_map'] = {} + + # Create a mapping from time to frame info + for i, info in enumerate(frame_info_results): + meta['frame_info_map'][ss_times[i]] = info + + if meta['debug']: + console.print(f"[cyan]Collected frame information for {len(frame_info_results)} frames") + + num_tasks = num_capture + num_workers = min(num_tasks, task_limit) + + meta['libplacebo'] = False + if tone_map and ("HDR" in meta['hdr'] or "DV" in meta['hdr'] or "HLG" in meta['hdr']): + if use_libplacebo: + if not ffmpeg_is_good: + test_time = ss_times[0] if ss_times else 0 + test_image = image_path if isinstance(image_path, str) else ( + image_path[0] if isinstance(image_path, list) and image_path else None + ) + libplacebo, compatible = await check_libplacebo_compatibility( + w_sar, h_sar, width, height, path, test_time, test_image, loglevel, meta + ) + if compatible: + hdr_tonemap = True + meta['tonemapped'] = True + if libplacebo: + hdr_tonemap = True + meta['tonemapped'] = True + meta['libplacebo'] = True + if not compatible and not libplacebo: + hdr_tonemap = False + console.print("[yellow]FFMPEG failed tonemap checking.[/yellow]") + await asyncio.sleep(2) + if not libplacebo and "HDR" not in meta.get('hdr'): + hdr_tonemap = False + else: + hdr_tonemap = True + meta['tonemapped'] = True + meta['libplacebo'] = True + else: + if "HDR" not in meta.get('hdr'): + hdr_tonemap = False + else: + hdr_tonemap = True + meta['tonemapped'] = True + else: + hdr_tonemap = False + + if meta['debug']: + console.print(f"Using {num_workers} worker(s) for {num_capture} image(s)") + + capture_tasks = [] + for i in range(num_capture): + image_index = existing_images_count + i + image_path = os.path.abspath(f"{base_dir}/tmp/{folder_id}/{sanitized_filename}-{image_index}.png") + if not os.path.exists(image_path) or meta.get('retake', False): + capture_tasks.append( + capture_screenshot( # Direct async function call + (i, path, ss_times[i], image_path, width, height, w_sar, h_sar, loglevel, hdr_tonemap, meta) + ) + ) + + try: + results = await asyncio.gather(*capture_tasks, return_exceptions=True) + capture_results = [r for r in results if isinstance(r, tuple) and len(r) == 2] + capture_results.sort(key=lambda x: x[0]) + capture_results = [r[1] for r in capture_results if r[1] is not None] + + except KeyboardInterrupt: + console.print("\n[red]CTRL+C detected. Cancelling capture tasks...[/red]") + await asyncio.sleep(0.1) + await kill_all_child_processes() + console.print("[red]All tasks cancelled. Exiting.[/red]") + gc.collect() + reset_terminal() + sys.exit(1) + except asyncio.CancelledError: + await asyncio.sleep(0.1) + await kill_all_child_processes() + gc.collect() + reset_terminal() + sys.exit(1) + except Exception: + await asyncio.sleep(0.1) + await kill_all_child_processes() + gc.collect() + reset_terminal() + sys.exit(1) + finally: + await asyncio.sleep(0.1) + await kill_all_child_processes() + if meta['debug']: + console.print("[yellow]All capture tasks finished. Cleaning up...[/yellow]") + + if not force_screenshots and meta['debug']: + console.print(f"[green]Successfully captured {len(capture_results)} screenshots.") + + optimized_results = [] + valid_images = [image for image in capture_results if os.path.exists(image)] + num_workers = min(task_limit, len(valid_images)) + if optimize_images: + if meta['debug']: + console.print("[yellow]Now optimizing images...[/yellow]") + console.print(f"Using {num_workers} worker(s) for {len(valid_images)} image(s)") + + executor = concurrent.futures.ProcessPoolExecutor(max_workers=num_workers) + try: + with executor: + # Start all tasks in parallel using worker_wrapper() + tasks = [asyncio.create_task(worker_wrapper(image, optimize_image_task, executor)) for image in valid_images] + + # Wait for all tasks to complete + optimized_results = await asyncio.gather(*tasks, return_exceptions=True) + except KeyboardInterrupt: + console.print("\n[red]CTRL+C detected. Cancelling optimization tasks...[/red]") + await asyncio.sleep(0.1) + executor.shutdown(wait=True, cancel_futures=True) + await kill_all_child_processes() + console.print("[red]All tasks cancelled. Exiting.[/red]") + gc.collect() + reset_terminal() + sys.exit(1) + except Exception as e: + console.print(f"[red]Error during image optimization: {e}[/red]") + await asyncio.sleep(0.1) + executor.shutdown(wait=True, cancel_futures=True) + await kill_all_child_processes() + gc.collect() + reset_terminal() + sys.exit(1) + finally: + if meta['debug']: + console.print("[yellow]Shutting down optimization workers...[/yellow]") + await asyncio.sleep(0.1) + executor.shutdown(wait=True, cancel_futures=True) + for task in tasks: + task.cancel() + await kill_all_child_processes() + gc.collect() + + # Filter out failed results + optimized_results = [res for res in optimized_results if isinstance(res, str) and "Error" not in res] + if not force_screenshots and meta['debug']: + console.print(f"[green]Successfully optimized {len(optimized_results)} images.[/green]") + else: + optimized_results = valid_images + + valid_results = [] + remaining_retakes = [] + for image_path in optimized_results: + if "Error" in image_path: + console.print(f"[red]{image_path}") + continue + + retake = False + image_size = os.path.getsize(image_path) + if meta['debug']: + console.print(f"[yellow]Checking image {image_path} (size: {image_size} bytes) for image host: {img_host}[/yellow]") + if not manual_frames: + if image_size <= 75000: + console.print(f"[yellow]Image {image_path} is incredibly small, retaking.") + retake = True + else: + if "imgbb" in img_host: + if image_size <= 31000000: + if meta['debug']: + console.print(f"[green]Image {image_path} meets size requirements for imgbb.[/green]") + else: + console.print(f"[red]Image {image_path} with size {image_size} bytes: does not meet size requirements for imgbb, retaking.") + retake = True + elif img_host in ["imgbox", "pixhost"]: + if 75000 < image_size <= 10000000: + if meta['debug']: + console.print(f"[green]Image {image_path} meets size requirements for {img_host}.[/green]") + else: + console.print(f"[red]Image {image_path} with size {image_size} bytes: does not meet size requirements for {img_host}, retaking.") + retake = True + elif img_host in ["ptpimg", "lensdump", "ptscreens", "onlyimage", "dalexni", "zipline", "passtheimage"]: + if meta['debug']: + console.print(f"[green]Image {image_path} meets size requirements for {img_host}.[/green]") + else: + console.print(f"[red]Unknown image host or image doesn't meet requirements for host: {img_host}, retaking.") + retake = True + + if retake: + retry_attempts = 5 + retry_offsets = [5.0, 10.0, -10.0, 100.0, -100.0] + frame_rate = meta.get('frame_rate', 24.0) + original_index = int(image_path.rsplit('-', 1)[-1].split('.')[0]) + original_time = ss_times[original_index] if 'ss_times' in locals() and original_index < len(ss_times) else None + + for attempt in range(1, retry_attempts + 1): + if original_time is not None: + for offset in retry_offsets: + adjusted_time = max(0, original_time + offset) + console.print(f"[yellow]Retaking screenshot for: {image_path} (Attempt {attempt}/{retry_attempts}) at {adjusted_time:.2f}s (offset {offset:+.2f}s)[/yellow]") + try: + if os.path.exists(image_path): + os.remove(image_path) + + screenshot_response = await capture_screenshot(( + original_index, path, adjusted_time, image_path, width, height, w_sar, h_sar, loglevel, hdr_tonemap, meta + )) + + if not isinstance(screenshot_response, tuple) or len(screenshot_response) != 2: + continue + + _, screenshot_path = screenshot_response + + if not screenshot_path or not os.path.exists(screenshot_path): + continue + + if optimize_images: + optimize_image_task(screenshot_path) + new_size = os.path.getsize(screenshot_path) + valid_image = False + + if "imgbb" in img_host: + if 75000 < new_size <= 31000000: + console.print(f"[green]Successfully retaken screenshot for: {screenshot_response} ({new_size} bytes)[/green]") + valid_image = True + elif img_host in ["imgbox", "pixhost"]: + if 75000 < new_size <= 10000000: + console.print(f"[green]Successfully retaken screenshot for: {screenshot_response} ({new_size} bytes)[/green]") + valid_image = True + elif img_host in ["ptpimg", "lensdump", "ptscreens", "onlyimage", "dalexni", "zipline", "passtheimage"]: + if new_size > 75000: + console.print(f"[green]Successfully retaken screenshot for: {screenshot_response} ({new_size} bytes)[/green]") + valid_image = True + + if valid_image: + valid_results.append(screenshot_response) + break + except Exception as e: + console.print(f"[red]Error retaking screenshot for {image_path} at {adjusted_time:.2f}s: {e}[/red]") + else: + continue + break + else: + # Fallback: use random time if original_time is not available + random_time = random.uniform(0, length) + console.print(f"[yellow]Retaking screenshot for: {image_path} (Attempt {attempt}/{retry_attempts}) at random time {random_time:.2f}s[/yellow]") + try: + if os.path.exists(image_path): + os.remove(image_path) + + screenshot_response = await capture_screenshot(( + original_index, path, random_time, image_path, width, height, w_sar, h_sar, loglevel, hdr_tonemap, meta + )) + + if not isinstance(screenshot_response, tuple) or len(screenshot_response) != 2: + continue + + _, screenshot_path = screenshot_response + + if not screenshot_path or not os.path.exists(screenshot_path): + continue + + if optimize_images: + optimize_image_task(screenshot_path) + new_size = os.path.getsize(screenshot_path) + valid_image = False + + if "imgbb" in img_host: + if 75000 < new_size <= 31000000: + valid_image = True + elif img_host in ["imgbox", "pixhost"]: + if 75000 < new_size <= 10000000: + valid_image = True + elif img_host in ["ptpimg", "lensdump", "ptscreens", "onlyimage", "dalexni", "zipline", "passtheimage"]: + if new_size > 75000: + valid_image = True + + if valid_image: + valid_results.append(screenshot_response) + break + except Exception as e: + console.print(f"[red]Error retaking screenshot for {image_path} at random time {random_time:.2f}s: {e}[/red]") + else: + console.print(f"[red]All retry attempts failed for {image_path}. Skipping.[/red]") + remaining_retakes.append(image_path) + gc.collect() + + else: + valid_results.append(image_path) + + if remaining_retakes: + console.print(f"[red]The following images could not be retaken successfully: {remaining_retakes}[/red]") + + if meta['debug']: + console.print(f"[green]Successfully processed {len(valid_results)} screenshots.") + + if meta['debug']: + finish_time = time.time() + console.print(f"Screenshots processed in {finish_time - start_time:.4f} seconds") + + multi_screens = int(config['DEFAULT'].get('multiScreens', 2)) + discs = meta.get('discs', []) + one_disc = True + if discs and len(discs) == 1: + one_disc = True + elif discs and len(discs) > 1: + one_disc = False + + if (not meta.get('tv_pack') and one_disc) or multi_screens == 0: + await cleanup() + + +async def capture_screenshot(args): + index, path, ss_time, image_path, width, height, w_sar, h_sar, loglevel, hdr_tonemap, meta = args + + try: + def set_ffmpeg_threads(): + threads_value = '1' + os.environ['FFREPORT'] = 'level=32' # Reduce ffmpeg logging overhead + return ['-threads', threads_value] + if width <= 0 or height <= 0: + return "Error: Invalid width or height for scaling" + + if ss_time < 0: + return f"Error: Invalid timestamp {ss_time}" + + # Normalize path for cross-platform compatibility + path = os.path.normpath(path) + + # If path is a directory and meta has a filelist, use the first file from the filelist + if os.path.isdir(path): + error_msg = f"Error: Path is a directory, not a file: {path}" + console.print(f"[yellow]{error_msg}[/yellow]") + + # Use meta that's passed directly to the function + if meta and isinstance(meta, dict) and 'filelist' in meta and meta['filelist']: + video_file = meta['filelist'][0] + console.print(f"[green]Using first file from filelist: {video_file}[/green]") + path = video_file + else: + return error_msg + + # After potential path correction, validate again + if not os.path.exists(path): + error_msg = f"Error: Input file does not exist: {path}" + console.print(f"[red]{error_msg}[/red]") + return error_msg + + # Debug output showing the exact path being used + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + console.print(f"[cyan]Processing file: {path}[/cyan]") + + if meta.get('frame_overlay', False): + # Warm-up (only for first screenshot index or if not warmed) + if use_libplacebo: + warm_up = config['DEFAULT'].get('ffmpeg_warmup', False) + if warm_up: + meta['_libplacebo_warmed'] = True + elif "_libplacebo_warmed" not in meta: + meta['_libplacebo_warmed'] = False + if hdr_tonemap and meta.get('libplacebo') and not meta.get('_libplacebo_warmed'): + await libplacebo_warmup(path, meta, loglevel) + + threads_value = set_ffmpeg_threads() + threads_val = threads_value[1] + vf_filters = [] + + if w_sar != 1 or h_sar != 1: + scaled_w = int(round(width * w_sar)) + scaled_h = int(round(height * h_sar)) + vf_filters.append(f"scale={scaled_w}:{scaled_h}") + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + console.print(f"[cyan]Applied PAR scale -> {scaled_w}x{scaled_h}[/cyan]") + + if hdr_tonemap: + if meta.get('libplacebo', False): + vf_filters.append( + "libplacebo=tonemapping=hable:colorspace=bt709:" + "color_primaries=bt709:color_trc=bt709:range=tv" + ) + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + console.print("[cyan]Using libplacebo tonemapping[/cyan]") + else: + vf_filters.extend([ + "zscale=transfer=linear", + f"tonemap=tonemap={algorithm}:desat={desat}", + "zscale=transfer=bt709" + ]) + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + console.print(f"[cyan]Using zscale tonemap chain (algo={algorithm}, desat={desat})[/cyan]") + + vf_filters.append("format=rgb24") + vf_chain = ",".join(vf_filters) if vf_filters else "format=rgb24" + + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + console.print(f"[cyan]Final -vf chain: {vf_chain}[/cyan]") + + threads_value = ['-threads', '1'] + threads_val = threads_value[1] + + def build_cmd(use_libplacebo=True): + cmd_local = [ + "ffmpeg", + "-ss", str(ss_time), + "-i", path, + "-map", "0:v:0", + "-an", + "-sn", + ] + if use_libplacebo and meta.get('libplacebo', False): + cmd_local += ["-init_hw_device", "vulkan"] + cmd_local += [ + "-vf", vf_chain, + "-vframes", "1", + "-pix_fmt", "rgb24", + "-y", + "-loglevel", loglevel, + ] + if ffmpeg_limit: + cmd_local += ["-threads", threads_val] + cmd_local.append(image_path) + return cmd_local + + cmd = build_cmd(use_libplacebo=True) + + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + # Disable emoji translation so 0:v:0 stays literal + console.print(f"[cyan]FFmpeg command: {' '.join(cmd)}[/cyan]", emoji=False) + + # --- Execute with retry/fallback if libplacebo fails --- + async def run_cmd(run_cmd_list, timeout_sec): + proc = await asyncio.create_subprocess_exec( + *run_cmd_list, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + try: + stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=timeout_sec) + except asyncio.TimeoutError: + proc.kill() + try: + await proc.wait() + except Exception: + pass + return -1, b"", b"Timeout" + return proc.returncode, stdout, stderr + + returncode, stdout, stderr = await run_cmd(cmd, 140) # a bit longer for first pass + if returncode != 0 and hdr_tonemap and meta.get('libplacebo'): + # Retry once (shader compile might have delayed first invocation) + if loglevel == 'verbose' or meta.get('debug', False): + console.print("[yellow]First libplacebo attempt failed; retrying once...[/yellow]") + await asyncio.sleep(1.0) + returncode, stdout, stderr = await run_cmd(cmd, 160) + + if returncode != 0 and hdr_tonemap and meta.get('libplacebo'): + # Fallback: switch to zscale tonemap chain + if loglevel == 'verbose' or meta.get('debug', False): + console.print("[red]libplacebo failed twice; falling back to zscale tonemap[/red]") + meta['libplacebo'] = False + # Rebuild chain with zscale + z_vf_filters = [] + if w_sar != 1 or h_sar != 1: + z_vf_filters.append(f"scale={scaled_w}:{scaled_h}") + z_vf_filters.extend([ + "zscale=transfer=linear", + f"tonemap=tonemap={algorithm}:desat={desat}", + "zscale=transfer=bt709", + "format=rgb24" + ]) + vf_chain = ",".join(z_vf_filters) + fallback_cmd = build_cmd(use_libplacebo=False) + # Replace the -vf argument with new chain + for i, tok in enumerate(fallback_cmd): + if tok == "-vf": + fallback_cmd[i+1] = vf_chain + break + if loglevel == 'verbose' or meta.get('debug', False): + console.print(f"[cyan]Fallback FFmpeg command: {' '.join(fallback_cmd)}[/cyan]", emoji=False) + returncode, stdout, stderr = await run_cmd(fallback_cmd, 140) + cmd = fallback_cmd # for logging below + + if returncode == 0 and os.path.exists(image_path): + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + console.print(f"[green]Screenshot captured successfully: {image_path}[/green]") + return (index, image_path) + else: + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + err_txt = (stderr or b"").decode(errors='replace').strip() + console.print(f"[red]FFmpeg process failed (final): {err_txt}[/red]") + return (index, None) + + # Proceed with screenshot capture + threads_value = set_ffmpeg_threads() + threads_val = threads_value[1] + if ffmpeg_limit: + ff = ( + ffmpeg + .input(path, ss=ss_time, threads=threads_val) + ) + else: + ff = ffmpeg.input(path, ss=ss_time) + ff = ff['v:0'] + if w_sar != 1 or h_sar != 1: + ff = ff.filter('scale', int(round(width * w_sar)), int(round(height * h_sar))) + + if hdr_tonemap: + ff = ( + ff + .filter('zscale', transfer='linear') + .filter('tonemap', tonemap=algorithm, desat=desat) + .filter('zscale', transfer='bt709') + .filter('format', 'rgb24') + ) + + if meta.get('frame_overlay', False): + # Get frame info from pre-collected data if available + frame_info = meta.get('frame_info_map', {}).get(ss_time, {}) + + frame_rate = meta.get('frame_rate', 24.0) + frame_number = int(ss_time * frame_rate) + + # If we have PTS time from frame info, use it to calculate a more accurate frame number + if 'pts_time' in frame_info: + # Only use PTS time for frame number calculation if it makes sense + # (sometimes seeking can give us a frame from the beginning instead of where we want) + pts_time = frame_info.get('pts_time', 0) + if pts_time > 1.0 and abs(pts_time - ss_time) < 10: + frame_number = int(pts_time * frame_rate) + + frame_type = frame_info.get('frame_type', 'Unknown') + + text_size = int(config['DEFAULT'].get('overlay_text_size', 18)) + # Get the resolution and convert it to integer + resol = int(''.join(filter(str.isdigit, meta.get('resolution', '1080p')))) + font_size = round(text_size*resol/1080) + x_all = round(10*resol/1080) + + # Scale vertical spacing based on font size + line_spacing = round(font_size * 1.1) + y_number = x_all + y_type = y_number + line_spacing + y_hdr = y_type + line_spacing + + # Use the filtered output with frame info + base_text = ff + + # Frame number + base_text = base_text.filter('drawtext', + text=f"Frame Number: {frame_number}", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_number, + box=1, + boxcolor='black@0.5' + ) + + # Frame type + base_text = base_text.filter('drawtext', + text=f"Frame Type: {frame_type}", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_type, + box=1, + boxcolor='black@0.5' + ) + + # HDR status + if hdr_tonemap: + base_text = base_text.filter('drawtext', + text="Tonemapped HDR", + fontcolor='white', + fontsize=font_size, + x=x_all, + y=y_hdr, + box=1, + boxcolor='black@0.5' + ) + + # Use the filtered output with frame info + ff = base_text + + if ffmpeg_limit: + command = ( + ff + .output(image_path, vframes=1, pix_fmt="rgb24", **{'threads': threads_val}) + .overwrite_output() + .global_args('-loglevel', loglevel) + ) + else: + command = ( + ff + .output(image_path, vframes=1, pix_fmt="rgb24") + .overwrite_output() + .global_args('-loglevel', loglevel) + ) + + # Print the command for debugging + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + cmd = command.compile() + console.print(f"[cyan]FFmpeg command: {' '.join(cmd)}[/cyan]") + + try: + returncode, stdout, stderr = await run_ffmpeg(command) + + # Print stdout and stderr if in verbose mode + if loglevel == 'verbose': + if stdout: + console.print(f"[blue]FFmpeg stdout:[/blue]\n{stdout.decode('utf-8', errors='replace')}") + if stderr: + console.print(f"[yellow]FFmpeg stderr:[/yellow]\n{stderr.decode('utf-8', errors='replace')}") + + except asyncio.CancelledError: + console.print(traceback.format_exc()) + raise + + if returncode == 0: + return (index, image_path) + else: + stderr_text = stderr.decode('utf-8', errors='replace') + if "Error initializing complex filters" in stderr_text: + console.print("[red]FFmpeg complex filters error: see https://github.com/Audionut/Upload-Assistant/wiki/ffmpeg---max-workers-issues[/red]") + else: + console.print(f"[red]FFmpeg error capturing screenshot: {stderr_text}[/red]") + return (index, None) + except Exception as e: + console.print(traceback.format_exc()) + return f"Error: {str(e)}" + + +async def valid_ss_time(ss_times, num_screens, length, frame_rate, meta, retake=False): + if meta['is_disc']: + total_screens = num_screens + 1 + else: + total_screens = num_screens + total_frames = int(length * frame_rate) + + # Track retake calls and adjust start frame accordingly + retake_offset = 0 + if retake and meta is not None: + if 'retake_call_count' not in meta: + meta['retake_call_count'] = 0 + + meta['retake_call_count'] += 1 + retake_offset = meta['retake_call_count'] * 0.01 + + if meta['debug']: + console.print(f"[cyan]Retake call #{meta['retake_call_count']}, adding {retake_offset:.1%} offset[/cyan]") + + # Calculate usable portion (from 1% to 90% of video) + if meta['category'] == "TV" and retake: + start_frame = int(total_frames * (0.1 + retake_offset)) + end_frame = int(total_frames * 0.9) + elif meta['category'] == "Movie" and retake: + start_frame = int(total_frames * (0.05 + retake_offset)) + end_frame = int(total_frames * 0.9) + else: + start_frame = int(total_frames * (0.05 + retake_offset)) + end_frame = int(total_frames * 0.9) + + # Ensure start_frame doesn't exceed reasonable bounds + max_start_frame = int(total_frames * 0.4) # Don't start beyond 40% + start_frame = min(start_frame, max_start_frame) + + usable_frames = end_frame - start_frame + chosen_frames = [] + + if total_screens > 1: + frame_interval = usable_frames // total_screens + else: + frame_interval = usable_frames + + result_times = ss_times.copy() + + for i in range(total_screens): + frame = start_frame + (i * frame_interval) + chosen_frames.append(frame) + time = frame / frame_rate + result_times.append(time) + + if meta['debug']: + console.print(f"[purple]Screenshots information:[/purple] \n[slate_blue3]Screenshots: [gold3]{total_screens}[/gold3] \nTotal Frames: [gold3]{total_frames}[/gold3]") + console.print(f"[slate_blue3]Start frame: [gold3]{start_frame}[/gold3] \nEnd frame: [gold3]{end_frame}[/gold3] \nUsable frames: [gold3]{usable_frames}[/gold3][/slate_blue3]") + console.print(f"[yellow]frame interval: {frame_interval} \n[purple]Chosen Frames[/purple]\n[gold3]{chosen_frames}[/gold3]\n") + + result_times = sorted(result_times) + return result_times + + +async def worker_wrapper(image, optimize_image_task, executor): + """ Async wrapper to run optimize_image_task in a separate process """ + loop = asyncio.get_running_loop() + try: + return await loop.run_in_executor(executor, optimize_image_task, image) + except KeyboardInterrupt: + console.print(f"[red][{time.strftime('%X')}] Worker interrupted while processing {image}[/red]") + gc.collect() + return None + except Exception as e: + console.print(f"[red][{time.strftime('%X')}] Worker error on {image}: {e}[/red]") + gc.collect() + return f"Error: {e}" + finally: + gc.collect() + + +async def kill_all_child_processes(): + """Ensures all child processes (e.g., ProcessPoolExecutor workers) are terminated.""" + current_process = psutil.Process() + children = current_process.children(recursive=True) # Get child processes once + + for child in children: + console.print(f"[red]Killing stuck worker process: {child.pid}[/red]") + child.terminate() + + gone, still_alive = psutil.wait_procs(children, timeout=3) # Wait for termination + for process in still_alive: + console.print(f"[red]Force killing stubborn process: {process.pid}[/red]") + process.kill() + + +def optimize_image_task(image): + """Optimizes an image using oxipng in a separate process.""" + try: + if optimize_images: + os.environ['RAYON_NUM_THREADS'] = threads + if not os.path.exists(image): + error_msg = f"ERROR: File not found - {image}" + console.print(f"[red]{error_msg}[/red]") + return error_msg + + pyver = platform.python_version_tuple() + if int(pyver[0]) == 3 and int(pyver[1]) >= 7: + level = 6 if os.path.getsize(image) >= 16000000 else 2 + + # Run optimization directly in the process + oxipng.optimize(image, level=level) + + return image + else: + return image + + except Exception as e: + error_message = f"ERROR optimizing {image}: {e}" + console.print(f"[red]{error_message}[/red]") + console.print(traceback.format_exc()) # Print detailed traceback + return None + + +async def get_frame_info(path, ss_time, meta): + """Get frame information (type, exact timestamp) for a specific frame""" + try: + info_ff = ffmpeg.input(path, ss=ss_time) + info_command = ( + info_ff + .filter('showinfo') + .output('-', format='null', vframes=1) + .global_args('-loglevel', 'info') + ) + + # Print the actual FFmpeg command for debugging + cmd = info_command.compile() + if meta.get('debug', False): + console.print(f"[cyan]FFmpeg showinfo command: {' '.join(cmd)}[/cyan]") + + returncode, _, stderr = await run_ffmpeg(info_command) + assert returncode is not None + stderr_text = stderr.decode('utf-8', errors='replace') + + # Calculate frame number based on timestamp and framerate + frame_rate = meta.get('frame_rate', 24.0) + calculated_frame = int(ss_time * frame_rate) + + # Default values + frame_info = { + 'frame_type': 'Unknown', + 'frame_number': calculated_frame + } + + pict_type_match = re.search(r'pict_type:(\w)', stderr_text) + if pict_type_match: + frame_info['frame_type'] = pict_type_match.group(1) + else: + # Try alternative patterns that might appear in newer FFmpeg versions + alt_match = re.search(r'type:(\w)\s', stderr_text) + if alt_match: + frame_info['frame_type'] = alt_match.group(1) + + pts_time_match = re.search(r'pts_time:(\d+\.\d+)', stderr_text) + if pts_time_match: + exact_time = float(pts_time_match.group(1)) + frame_info['pts_time'] = exact_time + # Recalculate frame number based on exact PTS time if available + frame_info['frame_number'] = int(exact_time * frame_rate) + + return frame_info + + except Exception as e: + console.print(f"[yellow]Error getting frame info: {e}. Will use estimated values.[/yellow]") + if meta.get('debug', False): + console.print(traceback.format_exc()) + return { + 'frame_type': 'Unknown', + 'frame_number': int(ss_time * meta.get('frame_rate', 24.0)) + } + + +async def check_libplacebo_compatibility(w_sar, h_sar, width, height, path, ss_time, image_path, loglevel, meta): + test_image_path = image_path.replace('.png', '_test.png') + + async def run_check(w_sar, h_sar, width, height, path, ss_time, image_path, loglevel, meta, try_libplacebo=False, test_image_path=None): + filter_parts = [] + input_label = "[0:v]" + output_map = "0:v" # Default output mapping + + if w_sar != 1 or h_sar != 1: + filter_parts.append(f"{input_label}scale={int(round(width * w_sar))}:{int(round(height * h_sar))}[scaled]") + input_label = "[scaled]" + output_map = "[scaled]" + + # Add libplacebo filter with output label + if try_libplacebo: + filter_parts.append(f"{input_label}libplacebo=tonemapping=auto:colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv[out]") + output_map = "[out]" + cmd = [ + "ffmpeg", + "-init_hw_device", "vulkan", + "-ss", str(ss_time), + "-i", path, + "-filter_complex", ",".join(filter_parts), + "-map", output_map, + "-vframes", "1", + "-pix_fmt", "rgb24", + "-y", + "-loglevel", "quiet", + test_image_path + ] + else: + # Use -vf for zscale/tonemap chain, no output label or -map needed + vf_chain = f"zscale=transfer=linear,tonemap=tonemap={algorithm}:desat={desat},zscale=transfer=bt709,format=rgb24" + cmd = [ + "ffmpeg", + "-ss", str(ss_time), + "-i", path, + "-vf", vf_chain, + "-vframes", "1", + "-pix_fmt", "rgb24", + "-y", + "-loglevel", "quiet", + test_image_path + ] + + if loglevel == 'verbose' or (meta and meta.get('debug', False)): + console.print(f"[cyan]libplacebo compatibility test command: {' '.join(cmd)}[/cyan]") + + # Add timeout to prevent hanging + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + try: + stdout, stderr = await asyncio.wait_for( + process.communicate(), + timeout=30.0 # 30 second timeout for compatibility test + ) + return process.returncode + except asyncio.TimeoutError: + console.print("[red]libplacebo compatibility test timed out after 30 seconds[/red]") + process.kill() + try: + await process.wait() + except Exception: + pass + return False + + if not meta['is_disc']: + is_libplacebo_compatible = await run_check(w_sar, h_sar, width, height, path, ss_time, image_path, loglevel, meta, try_libplacebo=True, test_image_path=test_image_path) + if is_libplacebo_compatible == 0: + if meta['debug']: + console.print("[green]libplacebo compatibility test succeeded[/green]") + try: + if os.path.exists(test_image_path): + os.remove(test_image_path) + except Exception: + pass + return True, True + else: + can_hdr = await run_check(w_sar, h_sar, width, height, path, ss_time, image_path, loglevel, meta, try_libplacebo=False, test_image_path=test_image_path) + if can_hdr == 0: + if meta['debug']: + console.print("[yellow]libplacebo compatibility test failed, but zscale HDR tonemapping is compatible[/yellow]") + # Clean up the test image regardless of success/failure + try: + if os.path.exists(test_image_path): + os.remove(test_image_path) + except Exception: + pass + return False, True + return False, False + + +async def libplacebo_warmup(path, meta, loglevel): + if not meta.get('libplacebo') or meta.get('_libplacebo_warmed'): + return + if not os.path.exists(path): + return + # Use a very small seek (0.1s) to avoid issues at pts 0 + cmd = [ + "ffmpeg", + "-ss", "0.1", + "-i", path, + "-map", "0:v:0", + "-an", "-sn", + "-init_hw_device", "vulkan", + "-vf", "libplacebo=tonemapping=hable:colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,format=rgb24", + "-vframes", "1", + "-f", "null", + "-", + "-loglevel", "error" + ] + if loglevel == 'verbose' or meta.get('debug', False): + console.print("[cyan]Running libplacebo warm-up...[/cyan]", emoji=False) + try: + proc = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + try: + await asyncio.wait_for(proc.communicate(), timeout=40) + except asyncio.TimeoutError: + proc.kill() + try: + await proc.wait() + except Exception: + pass + if loglevel == 'verbose' or meta.get('debug', False): + console.print("[yellow]libplacebo warm-up timed out (continuing anyway)[/yellow]") + meta['_libplacebo_warmed'] = True + except Exception as e: + if loglevel == 'verbose' or meta.get('debug', False): + console.print(f"[yellow]libplacebo warm-up failed: {e} (continuing)[/yellow]") + + +async def get_image_host(meta): + if meta.get('imghost') is not None: + host = meta['imghost'] + + if isinstance(host, str): + return host.lower().strip() + + elif isinstance(host, list): + for item in host: + if item and isinstance(item, str): + return item.lower().strip() + else: + img_host_config = [ + config["DEFAULT"][key].lower() + for key in sorted(config["DEFAULT"].keys()) + if key.startswith("img_host_1") and not key.endswith("0") + ] + if img_host_config: + return str(img_host_config[0]) diff --git a/src/tmdb.py b/src/tmdb.py new file mode 100644 index 000000000..7814e0e07 --- /dev/null +++ b/src/tmdb.py @@ -0,0 +1,1660 @@ +import anitopy +import asyncio +import cli_ui +import httpx +import json +import os +import re +import requests +import sys + +from datetime import datetime +from difflib import SequenceMatcher +from guessit import guessit + +from data.config import config +from src.args import Args +from src.cleanup import cleanup, reset_terminal +from src.console import console +from src.imdb import get_imdb_info_api + +TMDB_API_KEY = config['DEFAULT'].get('tmdb_api', False) +TMDB_BASE_URL = "https://api.themoviedb.org/3" +parser = Args(config=config) + + +async def normalize_title(title): + return title.lower().replace('&', 'and').replace(' ', ' ').strip() + + +async def get_tmdb_from_imdb(imdb_id, tvdb_id=None, search_year=None, filename=None, debug=False, mode="discord", category_preference=None, imdb_info=None): + """Fetches TMDb ID using IMDb or TVDb ID. + + - Returns `(category, tmdb_id, original_language)` + - If TMDb fails, prompts the user (if in CLI mode). + """ + if not str(imdb_id).startswith("tt"): + if isinstance(imdb_id, str) and imdb_id.isdigit(): + imdb_id = f"tt{int(imdb_id):07d}" + elif isinstance(imdb_id, int): + imdb_id = f"tt{imdb_id:07d}" + filename_search = False + + async def _tmdb_find_by_external_source(external_id, source): + """Helper function to find a movie or TV show on TMDb by external ID.""" + url = f"{TMDB_BASE_URL}/find/{external_id}" + params = {"api_key": TMDB_API_KEY, "external_source": source} + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, params=params, timeout=10) + response.raise_for_status() + return response.json() + except Exception: + console.print(f"[bold red]Failed to fetch TMDb data: {response.status_code}[/bold red]") + return {} + + return {} + + # Run a search by IMDb ID + info = await _tmdb_find_by_external_source(imdb_id, "imdb_id") + + # Check if both movie and TV results exist + has_movie_results = bool(info.get("movie_results")) + has_tv_results = bool(info.get("tv_results")) + + # If we have results in multiple categories but a category preference is set, respect that preference + if category_preference and has_movie_results and has_tv_results: + if category_preference == "MOVIE" and has_movie_results: + if debug: + console.print("[green]Found both movie and TV results, using movie based on preference") + return "MOVIE", info['movie_results'][0]['id'], info['movie_results'][0].get('original_language'), filename_search + elif category_preference == "TV" and has_tv_results: + if debug: + console.print("[green]Found both movie and TV results, using TV based on preference") + return "TV", info['tv_results'][0]['id'], info['tv_results'][0].get('original_language'), filename_search + + # If no preference or preference doesn't match available results, proceed with normal logic + if has_movie_results: + if debug: + console.print("Movie INFO", info) + return "MOVIE", info['movie_results'][0]['id'], info['movie_results'][0].get('original_language'), filename_search + + elif has_tv_results: + if debug: + console.print("TV INFO", info) + return "TV", info['tv_results'][0]['id'], info['tv_results'][0].get('original_language'), filename_search + + if debug: + console.print("[yellow]TMDb was unable to find anything with that IMDb ID, checking TVDb...") + + # Check TVDb for an ID if TVDb and still no results + if tvdb_id: + info_tvdb = await _tmdb_find_by_external_source(str(tvdb_id), "tvdb_id") + if debug: + console.print("TVDB INFO", info_tvdb) + if info_tvdb.get("tv_results"): + return "TV", info_tvdb['tv_results'][0]['id'], info_tvdb['tv_results'][0].get('original_language'), filename_search + + filename_search = True + + # If both TMDb and TVDb fail, fetch IMDb info and attempt a title search + imdb_id = imdb_id.replace("tt", "") + imdb_id = int(imdb_id) if imdb_id.isdigit() else 0 + imdb_info = imdb_info or await get_imdb_info_api(imdb_id, {}) + title = imdb_info.get("title") or filename + year = imdb_info.get("year") or search_year + original_language = imdb_info.get("original language", "en") + + console.print(f"[yellow]TMDb was unable to find anything from external IDs, searching TMDb for {title} ({year})[/yellow]") + + # Create meta dictionary with minimal required fields + meta = { + 'tmdb_id': 0, + 'category': "MOVIE", # Default to MOVIE + 'debug': debug, + 'mode': mode + } + + # Try as movie first + tmdb_id, category = await get_tmdb_id( + title, + year, + meta, + "MOVIE", + imdb_info.get('original title', imdb_info.get('localized title', None)) + ) + + # If no results, try as TV + if tmdb_id == 0: + meta['category'] = "TV" + tmdb_id, category = await get_tmdb_id( + title, + year, + meta, + "TV", + imdb_info.get('original title', imdb_info.get('localized title', None)) + ) + + # Extract necessary values from the result + tmdb_id = tmdb_id or 0 + category = category or "MOVIE" + + # **User Prompt for Manual TMDb ID Entry** + if tmdb_id in ('None', '', None, 0, '0') and mode == "cli": + console.print('[yellow]Unable to find a matching TMDb entry[/yellow]') + tmdb_id = console.input("Please enter TMDb ID (format: tv/12345 or movie/12345): ") + category, tmdb_id = parser.parse_tmdb_id(id=tmdb_id, category=category) + + return category, tmdb_id, original_language, filename_search + + +async def get_tmdb_id(filename, search_year, category, untouched_filename="", attempted=0, debug=False, secondary_title=None, path=None, final_attempt=None, new_category=None, unattended=False): + search_results = {"results": []} + original_category = category + if new_category: + category = new_category + else: + category = original_category + if final_attempt is None: + final_attempt = False + if attempted is None: + attempted = 0 + if attempted: + await asyncio.sleep(1) # Whoa baby, slow down + + async def search_tmdb_id(filename, search_year, category, untouched_filename="", attempted=0, debug=False, secondary_title=None, path=None, final_attempt=None, new_category=None, unattended=False): + search_results = {"results": []} + original_category = category + if new_category: + category = new_category + else: + category = original_category + if final_attempt is None: + final_attempt = False + if attempted is None: + attempted = 0 + if attempted: + await asyncio.sleep(1) # Whoa baby, slow down + async with httpx.AsyncClient() as client: + try: + # Primary search attempt with year + if category == "MOVIE": + if debug: + console.print(f"[green]Searching TMDb for movie:[/] [cyan]{filename}[/cyan] (Year: {search_year})") + + params = { + "api_key": TMDB_API_KEY, + "query": filename, + "language": "en-US", + "include_adult": "true" + } + + if search_year: + params["year"] = search_year + + response = await client.get(f"{TMDB_BASE_URL}/search/movie", params=params) + try: + response.raise_for_status() + search_results = response.json() + except Exception: + console.print(f"[bold red]Failure with primary movie search: {response.status_code}[/bold red]") + + elif category == "TV": + if debug: + console.print(f"[green]Searching TMDb for TV show:[/] [cyan]{filename}[/cyan] (Year: {search_year})") + + params = { + "api_key": TMDB_API_KEY, + "query": filename, + "language": "en-US", + "include_adult": "true" + } + + if search_year: + params["first_air_date_year"] = search_year + + response = await client.get(f"{TMDB_BASE_URL}/search/tv", params=params) + try: + response.raise_for_status() + search_results = response.json() + except Exception: + console.print(f"[bold red]Failed with primary TV search: {response.status_code}[/bold red]") + + if debug: + console.print(f"[yellow]TMDB search results (primary): {json.dumps(search_results.get('results', [])[:4], indent=2)}[/yellow]") + + # Check if results were found + results = search_results.get('results', []) + if results: + # Filter results by year if search_year is provided + if search_year: + def get_result_year(result): + return int((result.get('release_date') or result.get('first_air_date') or '0000')[:4] or 0) + filtered_results = [ + r for r in results + if abs(get_result_year(r) - int(search_year)) <= 2 + ] + limited_results = (filtered_results if filtered_results else results)[:8] + else: + limited_results = results[:8] + + if len(limited_results) == 1: + tmdb_id = int(limited_results[0]['id']) + return tmdb_id, category + elif len(limited_results) > 1: + filename_norm = await normalize_title(filename) + secondary_norm = await normalize_title(secondary_title) if secondary_title else None + search_year_int = int(search_year) if search_year else 0 + + # Find all exact matches (title and year) + exact_matches = [] + for r in limited_results: + if r.get('title'): + result_title = await normalize_title(r.get('title')) + else: + result_title = await normalize_title(r.get('name', '')) + if r.get('original_title'): + original_title = await normalize_title(r.get('original_title')) + else: + original_title = await normalize_title(r.get('original_name', '')) + result_year = int((r.get('release_date') or r.get('first_air_date') or '0')[:4] or 0) + # Only count as exact match if both years are present and non-zero + if secondary_norm and ( + secondary_norm == original_title + and search_year_int > 0 + and result_year > 0 + and (result_year == search_year_int or result_year == search_year_int + 1) + ): + exact_matches.append(r) + + if ( + filename_norm == result_title + and search_year_int > 0 + and result_year > 0 + and (result_year == search_year_int or result_year == search_year_int + 1) + ): + exact_matches.append(r) + + if secondary_norm and ( + secondary_norm == result_title + and search_year_int > 0 + and result_year > 0 + and (result_year == search_year_int or result_year == search_year_int + 1) + ): + exact_matches.append(r) + + summary_exact_matches = set((r['id'] for r in exact_matches)) + + if len(summary_exact_matches) == 1: + tmdb_id = int(summary_exact_matches.pop()) + return tmdb_id, category + + # If no exact matches, calculate similarity for all results and sort them + results_with_similarity = [] + for r in limited_results: + if r.get('title'): + result_title = await normalize_title(r.get('title')) + else: + result_title = await normalize_title(r.get('name', '')) + + if r.get('original_title'): + original_title = await normalize_title(r.get('original_title')) + else: + original_title = await normalize_title(r.get('original_name', '')) + + # Calculate similarity for both main title and original title + main_similarity = SequenceMatcher(None, filename_norm, result_title).ratio() + original_similarity = SequenceMatcher(None, filename_norm, original_title).ratio() + + # Try getting TMDb translation for original title if it's different + translated_title = "" + translated_similarity = 0.0 + secondary_best = 0.0 + + if original_title and original_title != result_title: + translated_title = await get_tmdb_translations(r['id'], category, 'en', debug) + if translated_title: + translated_title_norm = await normalize_title(translated_title) + translated_similarity = SequenceMatcher(None, filename_norm, translated_title_norm).ratio() + + if debug: + console.print(f"[cyan] TMDb translation: '{translated_title}' (similarity: {translated_similarity:.3f})[/cyan]") + + # Also calculate secondary title similarity if available + if secondary_norm is not None: + secondary_main_sim = SequenceMatcher(None, secondary_norm, result_title).ratio() + secondary_orig_sim = SequenceMatcher(None, secondary_norm, original_title).ratio() + secondary_trans_sim = 0.0 + + if translated_title: + translated_title_norm = await normalize_title(translated_title) + secondary_trans_sim = SequenceMatcher(None, secondary_norm, translated_title_norm).ratio() + + secondary_best = max(secondary_main_sim, secondary_orig_sim, secondary_trans_sim) + + if translated_similarity == 0.0: + if secondary_best == 0.0: + similarity = (main_similarity * 0.5) + (original_similarity * 0.5) + else: + similarity = (main_similarity * 0.3) + (original_similarity * 0.3) + (secondary_best * 0.4) + else: + if secondary_best == 0.0: + similarity = (main_similarity * 0.5) + (translated_similarity * 0.5) + else: + similarity = (main_similarity * 0.5) + (secondary_best * 0.5) + + result_year = int((r.get('release_date') or r.get('first_air_date') or '0')[:4] or 0) + + if debug: + console.print(f"[cyan]ID {r['id']}: '{result_title}' vs '{filename_norm}'[/cyan]") + console.print(f"[cyan] Main similarity: {main_similarity:.3f}[/cyan]") + console.print(f"[cyan] Original similarity: {original_similarity:.3f}[/cyan]") + if translated_similarity > 0: + console.print(f"[cyan] Translated similarity: {translated_similarity:.3f}[/cyan]") + if secondary_best > 0: + console.print(f"[cyan] Secondary similarity: {secondary_best:.3f}[/cyan]") + console.print(f"[cyan] Final similarity: {similarity:.3f}[/cyan]") + + # Boost similarity if we have exact matches with year validation + if similarity >= 0.9 and search_year_int > 0 and result_year > 0: + if result_year == search_year_int: + similarity += 0.1 # Full boost for exact year match + elif result_year == search_year_int + 1: + similarity += 0.1 # Boost for +1 year (handles TMDB/IMDb differences) + + results_with_similarity.append((r, similarity)) + + # Give a slight boost to the first result for TV shows (often the main series) + if category == "TV" and results_with_similarity: + first_result = results_with_similarity[0] + # Boost the first result's similarity by 0.05 (5%) + boosted_similarity = first_result[1] + 0.05 + results_with_similarity[0] = (first_result[0], boosted_similarity) + + if debug: + console.print(f"[cyan]Boosted first TV result similarity from {first_result[1]:.3f} to {boosted_similarity:.3f}[/cyan]") + + # Sort by similarity (highest first) + results_with_similarity.sort(key=lambda x: x[1], reverse=True) + sorted_results = [r[0] for r in results_with_similarity] + + # Filter results: if we have high similarity matches (>= 0.90), hide low similarity ones (< 0.75) + best_similarity = results_with_similarity[0][1] + if best_similarity >= 0.90: + # Filter out results with similarity < 0.75 + filtered_results_with_similarity = [ + (result, sim) for result, sim in results_with_similarity + if sim >= 0.75 + ] + results_with_similarity = filtered_results_with_similarity + sorted_results = [r[0] for r in results_with_similarity] + + if debug: + console.print(f"[yellow]Filtered out low similarity results (< 0.70) since best match has {best_similarity:.2f} similarity[/yellow]") + else: + sorted_results = [r[0] for r in results_with_similarity] + + # Check if the best match is significantly better than others + best_similarity = results_with_similarity[0][1] + similarity_threshold = 0.70 + + if best_similarity >= similarity_threshold: + # Check that no other result is close to the best match + second_best = results_with_similarity[1][1] if len(results_with_similarity) > 1 else 0.0 + if best_similarity >= 0.75 and best_similarity - second_best >= 0.10: + if debug: + console.print(f"[green]Auto-selecting best match: {sorted_results[0].get('title') or sorted_results[0].get('name')} (similarity: {best_similarity:.2f}[/green]") + tmdb_id = int(sorted_results[0]['id']) + return tmdb_id, category + + # Check for "The" prefix handling + if len(results_with_similarity) > 1: + the_results = [] + non_the_results = [] + + for result_tuple in results_with_similarity: + result, similarity = result_tuple + if result.get('title'): + title = await normalize_title(result.get('title')) + else: + title = await normalize_title(result.get('name', '')) + if title.startswith('the '): + the_results.append(result_tuple) + else: + non_the_results.append(result_tuple) + + # If exactly one result starts with "The", check if similarity improves + if len(the_results) == 1 and len(non_the_results) > 0: + the_result, the_similarity = the_results[0] + if the_result.get('title'): + the_title = await normalize_title(the_result.get('title')) + else: + the_title = await normalize_title(the_result.get('name', '')) + the_title_without_the = the_title[4:] + new_similarity = SequenceMatcher(None, filename_norm, the_title_without_the).ratio() + + if debug: + console.print(f"[cyan]Checking 'The' prefix: '{the_title}' -> '{the_title_without_the}'[/cyan]") + console.print(f"[cyan]Original similarity: {the_similarity:.3f}, New similarity: {new_similarity:.3f}[/cyan]") + + # If similarity improves significantly, update and resort + if new_similarity > the_similarity + 0.05: + if debug: + console.print("[green]'The' prefix removal improved similarity, updating results[/green]") + + updated_results = [] + for result_tuple in results_with_similarity: + result, similarity = result_tuple + if result['id'] == the_result['id']: + updated_results.append((result, new_similarity)) + else: + updated_results.append(result_tuple) + + # Resort by similarity + updated_results.sort(key=lambda x: x[1], reverse=True) + results_with_similarity = updated_results + sorted_results = [r[0] for r in results_with_similarity] + best_similarity = results_with_similarity[0][1] + second_best = results_with_similarity[1][1] if len(results_with_similarity) > 1 else 0.0 + + if best_similarity >= 0.75 and best_similarity - second_best >= 0.10: + if debug: + console.print(f"[green]Auto-selecting 'The' prefixed match: {sorted_results[0].get('title') or sorted_results[0].get('name')} (similarity: {best_similarity:.2f})[/green]") + tmdb_id = int(sorted_results[0]['id']) + return tmdb_id, category + + # Put unattended handling here, since it will work based on the sorted results + if unattended: + tmdb_id = int(sorted_results[0]['id']) + return tmdb_id, category + + # Show sorted results to user + console.print() + console.print("[bold yellow]Multiple TMDb results found. Please select the correct entry:[/bold yellow]") + if category == "MOVIE": + tmdb_url = "https://www.themoviedb.org/movie/" + else: + tmdb_url = "https://www.themoviedb.org/tv/" + + for idx, result in enumerate(sorted_results): + title = result.get('title') or result.get('name', '') + year = result.get('release_date', result.get('first_air_date', ''))[:4] + overview = result.get('overview', '') + similarity_score = results_with_similarity[idx][1] + + console.print(f"[cyan]{idx+1}.[/cyan] [bold]{title}[/bold] ({year}) [yellow]ID:[/yellow] {tmdb_url}{result['id']} [dim](similarity: {similarity_score:.2f})[/dim]") + if overview: + console.print(f"[green]Overview:[/green] {overview[:200]}{'...' if len(overview) > 200 else ''}") + console.print() + + selection = None + while True: + console.print("Enter the number of the correct entry, or manual TMDb ID (tv/12345 or movie/12345):") + try: + selection = cli_ui.ask_string("Or push enter to try a different search: ") + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + try: + # Check if it's a manual TMDb ID entry + if '/' in selection and (selection.lower().startswith('tv/') or selection.lower().startswith('movie/')): + try: + parsed_category, parsed_tmdb_id = parser.parse_tmdb_id(selection, category) + if parsed_tmdb_id and parsed_tmdb_id != 0: + console.print(f"[green]Using manual TMDb ID: {parsed_tmdb_id} and category: {parsed_category}[/green]") + return int(parsed_tmdb_id), parsed_category + else: + console.print("[bold red]Invalid TMDb ID format. Please try again.[/bold red]") + continue + except Exception as e: + console.print(f"[bold red]Error parsing TMDb ID: {e}. Please try again.[/bold red]") + continue + except KeyboardInterrupt: + console.print("\n[bold red]Search cancelled by user.[/bold red]") + sys.exit(0) + + # Handle numeric selection + selection_int = int(selection) + if 1 <= selection_int <= len(sorted_results): + tmdb_id = int(sorted_results[selection_int - 1]['id']) + return tmdb_id, category + else: + console.print("[bold red]Selection out of range. Please try again.[/bold red]") + except ValueError: + console.print("[bold red]Invalid input. Please enter a number or TMDb ID (tv/12345 or movie/12345).[/bold red]") + except KeyboardInterrupt: + console.print("\n[bold red]Search cancelled by user.[/bold red]") + sys.exit(0) + + except Exception: + search_results = {"results": []} # Reset search_results on exception + + # TMDb doesn't do roman + if not search_results.get('results'): + try: + words = filename.split() + roman_numerals = { + 'II': '2', 'III': '3', 'IV': '4', 'V': '5', + 'VI': '6', 'VII': '7', 'VIII': '8', 'IX': '9', 'X': '10' + } + + converted = False + for i, word in enumerate(words): + if word.upper() in roman_numerals: + words[i] = roman_numerals[word.upper()] + converted = True + + if converted: + converted_title = ' '.join(words) + if debug: + console.print(f"[bold yellow]Trying with roman numerals converted: {converted_title}[/bold yellow]") + result = await search_tmdb_id(converted_title, search_year, original_category, untouched_filename, attempted + 1, debug=debug, secondary_title=secondary_title, path=path, unattended=unattended) + if result and result != (0, category): + return result + except Exception as e: + console.print(f"[bold red]Roman numeral conversion error:[/bold red] {e}") + search_results = {"results": []} + + # If we have a secondary title, try searching with that + if secondary_title: + if debug: + console.print(f"[yellow]Trying secondary title: {secondary_title}[/yellow]") + result = await search_tmdb_id( + secondary_title, + search_year, + category, + untouched_filename, + debug=debug, + secondary_title=secondary_title, + path=path, + unattended=unattended + ) + if result and result != (0, category): + return result + + # Try searching with the primary filename + if debug: + console.print(f"[yellow]Trying primary filename: {filename}[/yellow]") + if not search_results.get('results'): + result = await search_tmdb_id( + filename, + search_year, + category, + untouched_filename, + debug=debug, + secondary_title=secondary_title, + path=path, + unattended=unattended + ) + if result and result != (0, category): + return result + + # Try searching with year + 1 if search_year is provided + if not search_results.get('results'): + try: + year_int = int(search_year) + except Exception: + year_int = 0 + + if year_int > 0: + imdb_year = year_int + 1 + if debug: + console.print("[yellow]Retrying with year +1...[/yellow]") + result = await search_tmdb_id(filename, imdb_year, category, untouched_filename, attempted + 1, debug=debug, secondary_title=secondary_title, path=path, unattended=unattended) + if result and result != (0, category): + return result + + # Try switching category + if not search_results.get('results'): + new_category = "TV" if category == "MOVIE" else "MOVIE" + if debug: + console.print(f"[bold yellow]Switching category to {new_category} and retrying...[/bold yellow]") + result = await search_tmdb_id(filename, search_year, category, untouched_filename, attempted + 1, debug=debug, secondary_title=secondary_title, path=path, new_category=new_category, unattended=unattended) + if result and result != (0, category): + return result + + # try anime name parsing + if not search_results.get('results'): + try: + parsed_title = anitopy.parse( + guessit(untouched_filename, {"excludes": ["country", "language"]})['title'] + )['anime_title'] + if debug: + console.print(f"[bold yellow]Trying parsed anime title: {parsed_title}[/bold yellow]") + result = await search_tmdb_id(parsed_title, search_year, original_category, untouched_filename, attempted + 1, debug=debug, secondary_title=secondary_title, path=path, unattended=unattended) + if result and result != (0, category): + return result + except KeyError: + console.print("[bold red]Failed to parse title for TMDb search.[/bold red]") + search_results = {"results": []} + + # Try with less words in the title + if not search_results.get('results'): + try: + words = filename.split() + extensions = ['mp4', 'mkv', 'avi', 'webm', 'mov', 'wmv'] + words_lower = [word.lower() for word in words] + + for ext in extensions: + if ext in words_lower: + ext_index = words_lower.index(ext) + words.pop(ext_index) + words_lower.pop(ext_index) + break + + if len(words) >= 2: + title = ' '.join(words[:-1]) + if debug: + console.print(f"[bold yellow]Trying reduced name: {title}[/bold yellow]") + result = await search_tmdb_id(title, search_year, original_category, untouched_filename, attempted + 1, debug=debug, secondary_title=secondary_title, path=path, unattended=unattended) + if result and result != (0, category): + return result + except Exception as e: + console.print(f"[bold red]Reduced name search error:[/bold red] {e}") + search_results = {"results": []} + + # Try with even less words + if not search_results.get('results'): + try: + words = filename.split() + extensions = ['mp4', 'mkv', 'avi', 'webm', 'mov', 'wmv'] + words_lower = [word.lower() for word in words] + + for ext in extensions: + if ext in words_lower: + ext_index = words_lower.index(ext) + words.pop(ext_index) + words_lower.pop(ext_index) + break + + if len(words) >= 3: + title = ' '.join(words[:-2]) + if debug: + console.print(f"[bold yellow]Trying further reduced name: {title}[/bold yellow]") + result = await search_tmdb_id(title, search_year, original_category, untouched_filename, attempted + 1, debug=debug, secondary_title=secondary_title, path=path, unattended=unattended) + if result and result != (0, category): + return result + except Exception as e: + console.print(f"[bold red]Reduced name search error:[/bold red] {e}") + search_results = {"results": []} + + # No match found, prompt user if in CLI mode + console.print("[bold red]Unable to find TMDb match using any search[/bold red]") + try: + tmdb_id = cli_ui.ask_string("Please enter TMDb ID in this format: tv/12345 or movie/12345") + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + category, tmdb_id = parser.parse_tmdb_id(id=tmdb_id, category=category) + + return tmdb_id, category + + +async def tmdb_other_meta( + tmdb_id, + path=None, + search_year=None, + category=None, + imdb_id=0, + manual_language=None, + anime=False, + mal_manual=None, + aka='', + original_language=None, + poster=None, + debug=False, + mode="discord", + tvdb_id=0, + quickie_search=False, + filename=None +): + """ + Fetch metadata from TMDB for a movie or TV show. + Returns a dictionary containing metadata that can be used to update the meta object. + """ + tmdb_metadata = {} + + # Initialize variables that might not be set in all code paths + backdrop = "" + cast = [] + certification = "" + creators = [] + demographic = "" + directors = [] + genre_ids = "" + genres = "" + imdb_mismatch = False + keywords = "" + logo_path = "" + mal_id = 0 + mismatched_imdb_id = 0 + origin_country = [] + original_title = "" + overview = "" + poster_path = "" + retrieved_aka = "" + runtime = 60 + title = None + tmdb_type = "" + year = None + youtube = None + + if tmdb_id == 0: + try: + title = guessit(path, {"excludes": ["country", "language"]})['title'].lower() + title = title.split('aka')[0] + result = await get_tmdb_id( + guessit(title, {"excludes": ["country", "language"]})['title'], + search_year, + {'tmdb_id': 0, 'search_year': search_year, 'debug': debug, 'category': category, 'mode': mode}, + category + ) + + if result['tmdb_id'] == 0: + result = await get_tmdb_id( + title, + "", + {'tmdb_id': 0, 'search_year': "", 'debug': debug, 'category': category, 'mode': mode}, + category + ) + + tmdb_id = result['tmdb_id'] + + if tmdb_id == 0: + if mode == 'cli': + console.print("[bold red]Unable to find tmdb entry. Exiting.") + exit() + else: + console.print("[bold red]Unable to find tmdb entry") + return {} + except Exception: + if mode == 'cli': + console.print("[bold red]Unable to find tmdb entry. Exiting.") + exit() + else: + console.print("[bold red]Unable to find tmdb entry") + return {} + + youtube = None + title = None + year = None + original_imdb_id = imdb_id + + async with httpx.AsyncClient() as client: + # Get main media details first (movie or TV show) + main_url = f"{TMDB_BASE_URL}/{('movie' if category == 'MOVIE' else 'tv')}/{tmdb_id}" + + # Make the main API call to get basic data + response = await client.get(main_url, params={"api_key": TMDB_API_KEY}) + try: + response.raise_for_status() + media_data = response.json() + except Exception: + console.print(f"[bold red]Failed to fetch media data: {response.status_code}[/bold red]") + return {} + + if debug: + console.print(f"[cyan]TMDB Response: {json.dumps(media_data, indent=2)[:1200]}...") + + # Extract basic info from media_data + if category == "MOVIE": + title = media_data['title'] + original_title = media_data.get('original_title', title) + year = datetime.strptime(media_data['release_date'], '%Y-%m-%d').year if media_data['release_date'] else search_year + runtime = media_data.get('runtime', 60) + if quickie_search or not imdb_id: + imdb_id_str = str(media_data.get('imdb_id', '')).replace('tt', '') + if imdb_id_str and imdb_id_str.isdigit(): + if imdb_id and int(imdb_id_str) != imdb_id: + imdb_mismatch = True + mismatched_imdb_id = int(imdb_id_str) + imdb_id = original_imdb_id + else: + imdb_id = original_imdb_id + + tmdb_type = 'Movie' + else: # TV show + title = media_data['name'] + original_title = media_data.get('original_name', title) + year = datetime.strptime(media_data['first_air_date'], '%Y-%m-%d').year if media_data['first_air_date'] else search_year + if not year: + year_pattern = r'(18|19|20)\d{2}' + year_match = re.search(year_pattern, title) + if year_match: + year = int(year_match.group(0)) + if not year: + year = datetime.strptime(media_data['last_air_date'], '%Y-%m-%d').year if media_data['last_air_date'] else 0 + runtime_list = media_data.get('episode_run_time', [60]) + runtime = runtime_list[0] if runtime_list else 60 + tmdb_type = media_data.get('type', 'Scripted') + + overview = media_data['overview'] + original_language_from_tmdb = media_data['original_language'] + + poster_path = media_data.get('poster_path', '') + if poster is None and poster_path: + poster = f"https://image.tmdb.org/t/p/original{poster_path}" + + backdrop = media_data.get('backdrop_path', '') + if backdrop: + backdrop = f"https://image.tmdb.org/t/p/original{backdrop}" + + # Prepare all API endpoints for concurrent requests + endpoints = [ + # External IDs + client.get(f"{main_url}/external_ids", params={"api_key": TMDB_API_KEY}), + # Videos + client.get(f"{main_url}/videos", params={"api_key": TMDB_API_KEY}), + # Keywords + client.get(f"{main_url}/keywords", params={"api_key": TMDB_API_KEY}), + # Credits + client.get(f"{main_url}/credits", params={"api_key": TMDB_API_KEY}) + ] + + # Add logo request if needed + if config['DEFAULT'].get('add_logo', False): + endpoints.append( + client.get(f"{TMDB_BASE_URL}/{('movie' if category == 'MOVIE' else 'tv')}/{tmdb_id}/images", + params={"api_key": TMDB_API_KEY}) + ) + + # Make all requests concurrently + results = await asyncio.gather(*endpoints, return_exceptions=True) + + # Process results with the correct indexing + external_data, videos_data, keywords_data, credits_data, *rest = results + idx = 0 + logo_data = None + + # Get logo data if it was requested + if config['DEFAULT'].get('add_logo', False): + logo_data = rest[idx] + idx += 1 + + # Process external IDs + if isinstance(external_data, Exception): + console.print("[bold red]Failed to fetch external IDs[/bold red]") + else: + try: + external = external_data.json() + # Process IMDB ID + if quickie_search or imdb_id == 0: + imdb_id_str = external.get('imdb_id', None) + if isinstance(imdb_id_str, str) and imdb_id_str not in ["", " ", "None", "null"]: + imdb_id_clean = imdb_id_str.lstrip('t') + if imdb_id_clean.isdigit(): + imdb_id_clean_int = int(imdb_id_clean) + if imdb_id_clean_int != int(original_imdb_id) and quickie_search and original_imdb_id != 0: + imdb_mismatch = True + mismatched_imdb_id = imdb_id_clean_int + else: + imdb_id = int(imdb_id_clean) + else: + imdb_id = original_imdb_id + else: + imdb_id = original_imdb_id + else: + imdb_id = original_imdb_id + + # Process TVDB ID + if tvdb_id == 0: + tvdb_id_str = external.get('tvdb_id', None) + if isinstance(tvdb_id_str, str) and tvdb_id_str not in ["", " ", "None", "null"]: + tvdb_id = int(tvdb_id_str) if tvdb_id_str.isdigit() else 0 + else: + tvdb_id = 0 + except Exception: + console.print("[bold red]Failed to process external IDs[/bold red]") + + # Process videos + if isinstance(videos_data, Exception): + console.print("[yellow]Unable to grab videos from TMDb.[/yellow]") + else: + try: + videos = videos_data.json() + for each in videos.get('results', []): + if each.get('site', "") == 'YouTube' and each.get('type', "") == "Trailer": + youtube = f"https://www.youtube.com/watch?v={each.get('key')}" + break + except Exception: + console.print("[yellow]Unable to process videos from TMDb.[/yellow]") + + # Process keywords + if isinstance(keywords_data, Exception): + console.print("[bold red]Failed to fetch keywords[/bold red]") + keywords = "" + else: + try: + kw_json = keywords_data.json() + if category == "MOVIE": + keywords = ', '.join([keyword['name'].replace(',', ' ') for keyword in kw_json.get('keywords', [])]) + else: # TV + keywords = ', '.join([keyword['name'].replace(',', ' ') for keyword in kw_json.get('results', [])]) + except Exception: + console.print("[bold red]Failed to process keywords[/bold red]") + keywords = "" + + origin_country = list(media_data.get("origin_country", [])) + + # Process credits + creators = [] + for each in media_data.get("created_by", []): + name = each.get('original_name') or each.get('name') + if name: + creators.append(name) + # Limit to the first 5 unique names + creators = list(dict.fromkeys(creators))[:5] + + if isinstance(credits_data, Exception): + console.print("[bold red]Failed to fetch credits[/bold red]") + directors = [] + cast = [] + else: + try: + credits = credits_data.json() + directors = [] + cast = [] + for each in credits.get('cast', []) + credits.get('crew', []): + if each.get('known_for_department', '') == "Directing" or each.get('job', '') == "Director": + directors.append(each.get('original_name', each.get('name'))) + elif each.get('known_for_department', '') == "Acting" or each.get('job', '') in {"Actor", "Actress"}: + cast.append(each.get('original_name', each.get('name'))) + # Limit to the first 5 unique names + directors = list(dict.fromkeys(directors))[:5] + cast = list(dict.fromkeys(cast))[:5] + except Exception: + console.print("[bold red]Failed to process credits[/bold red]") + directors = [] + cast = [] + + # Process genres + genres_data = await get_genres(media_data) + genres = genres_data['genre_names'] + genre_ids = genres_data['genre_ids'] + + # Process logo if needed + if config['DEFAULT'].get('add_logo', False) and logo_data and not isinstance(logo_data, Exception): + try: + logo_json = logo_data.json() + logo_path = await get_logo(tmdb_id, category, debug, TMDB_API_KEY=TMDB_API_KEY, TMDB_BASE_URL=TMDB_BASE_URL, logo_json=logo_json) + except Exception: + console.print("[yellow]Failed to process logo[/yellow]") + logo_path = "" + + # Use retrieved original language or fallback to TMDB's value + if manual_language: + original_language = manual_language + else: + original_language = original_language_from_tmdb + + # Get anime information if applicable + if not anime: + if category == "MOVIE": + filename = filename + else: + filename = path + mal_id, retrieved_aka, anime, demographic = await get_anime( + media_data, + {'title': title, 'aka': retrieved_aka, 'mal_id': 0, 'filename': filename} + ) + + if mal_manual is not None and mal_manual != 0: + mal_id = mal_manual + + # Check if AKA is too similar to title and clear it if needed + if retrieved_aka: + difference = SequenceMatcher(None, title.lower(), retrieved_aka[5:].lower()).ratio() + if difference >= 0.7 or retrieved_aka[5:].strip() == "" or retrieved_aka[5:].strip().lower() in title.lower(): + retrieved_aka = "" + if year and f"({year})" in retrieved_aka: + retrieved_aka = retrieved_aka.replace(f"({year})", "").strip() + + # Build the metadata dictionary + tmdb_metadata = { + 'title': title, + 'year': year, + 'imdb_id': imdb_id, + 'tvdb_id': tvdb_id, + 'origin_country': origin_country, + 'original_language': original_language, + 'original_title': original_title, + 'keywords': keywords, + 'genres': genres, + 'genre_ids': genre_ids, + 'tmdb_creators': creators, + 'tmdb_directors': directors, + 'tmdb_cast': cast, + 'mal_id': mal_id, + 'anime': anime, + 'demographic': demographic, + 'retrieved_aka': retrieved_aka, + 'poster': poster, + 'tmdb_poster': poster_path, + 'logo': logo_path, + 'backdrop': backdrop, + 'overview': overview, + 'tmdb_type': tmdb_type, + 'runtime': runtime, + 'youtube': youtube, + 'certification': certification, + 'imdb_mismatch': imdb_mismatch, + 'mismatched_imdb_id': mismatched_imdb_id + } + + return tmdb_metadata + + +async def get_keywords(tmdb_id, category): + """Get keywords for a movie or TV show using httpx""" + endpoint = "movie" if category == "MOVIE" else "tv" + url = f"{TMDB_BASE_URL}/{endpoint}/{tmdb_id}/keywords" + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, params={"api_key": TMDB_API_KEY}) + try: + response.raise_for_status() + data = response.json() + except Exception: + console.print(f"[bold red]Failed to fetch keywords: {response.status_code}[/bold red]") + return "" + + if category == "MOVIE": + keywords = [keyword['name'].replace(',', ' ') for keyword in data.get('keywords', [])] + else: # TV + keywords = [keyword['name'].replace(',', ' ') for keyword in data.get('results', [])] + + return ', '.join(keywords) + except Exception as e: + console.print(f'[yellow]Failed to get keywords: {str(e)}') + return '' + + +async def get_genres(response_data): + """Extract genres from TMDB response data""" + if response_data is not None: + tmdb_genres = response_data.get('genres', []) + + if tmdb_genres: + # Extract genre names and IDs + genre_names = [genre['name'].replace(',', ' ') for genre in tmdb_genres] + genre_ids = [str(genre['id']) for genre in tmdb_genres] + + # Create and return both strings + return { + 'genre_names': ', '.join(genre_names), + 'genre_ids': ', '.join(genre_ids) + } + + # Return empty values if no genres found + return { + 'genre_names': '', + 'genre_ids': '' + } + + +async def get_directors(tmdb_id, category): + """Get directors for a movie or TV show using httpx""" + endpoint = "movie" if category == "MOVIE" else "tv" + url = f"{TMDB_BASE_URL}/{endpoint}/{tmdb_id}/credits" + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, params={"api_key": TMDB_API_KEY}) + try: + response.raise_for_status() + data = response.json() + except Exception: + console.print(f"[bold red]Failed to fetch credits: {response.status_code}[/bold red]") + return [] + + directors = [] + for each in data.get('cast', []) + data.get('crew', []): + if each.get('known_for_department', '') == "Directing" or each.get('job', '') == "Director": + directors.append(each.get('original_name', each.get('name'))) + return directors + except Exception as e: + console.print(f'[yellow]Failed to get directors: {str(e)}') + return [] + + +async def get_anime(response, meta): + tmdb_name = meta['title'] + if meta.get('aka', "") == "": + alt_name = "" + else: + alt_name = meta['aka'] + anime = False + animation = False + demographic = '' + for each in response['genres']: + if each['id'] == 16: + animation = True + if response['original_language'] == 'ja' and animation is True: + romaji, mal_id, eng_title, season_year, episodes, demographic = await get_romaji(tmdb_name, meta.get('mal_id', None), meta) + alt_name = f" AKA {romaji}" + + anime = True + # mal = AnimeSearch(romaji) + # mal_id = mal.results[0].mal_id + else: + mal_id = 0 + if meta.get('mal_id', 0) != 0: + mal_id = meta.get('mal_id') + return mal_id, alt_name, anime, demographic + + +async def get_romaji(tmdb_name, mal, meta): + media = [] + demographic = 'Mina' # Default to Mina if no tags are found + + # Try AniList query with tmdb_name first, then fallback to meta['filename'] if no results + for search_term in [tmdb_name, meta.get('filename', '')]: + if not search_term: + continue + if mal is None or mal == 0: + cleaned_name = search_term.replace('-', "").replace("The Movie", "") + cleaned_name = ' '.join(cleaned_name.split()) + query = ''' + query ($search: String) { + Page (page: 1) { + pageInfo { + total + } + media (search: $search, type: ANIME, sort: SEARCH_MATCH) { + id + idMal + title { + romaji + english + native + } + seasonYear + episodes + tags { + name + } + externalLinks { + id + url + site + siteId + } + } + } + } + ''' + variables = {'search': cleaned_name} + else: + query = ''' + query ($search: Int) { + Page (page: 1) { + pageInfo { + total + } + media (idMal: $search, type: ANIME, sort: SEARCH_MATCH) { + id + idMal + title { + romaji + english + native + } + seasonYear + episodes + tags { + name + } + } + } + } + ''' + variables = {'search': mal} + + url = 'https://graphql.anilist.co' + try: + response = requests.post(url, json={'query': query, 'variables': variables}) + json_data = response.json() + + demographics = ["Shounen", "Seinen", "Shoujo", "Josei", "Kodomo", "Mina"] + for tag in demographics: + if tag in response.text: + demographic = tag + break + + media = json_data['data']['Page']['media'] + if media not in (None, []): + break # Found results, stop retrying + except Exception: + console.print('[red]Failed to get anime specific info from anilist. Continuing without it...') + media = [] + if "subsplease" in meta.get('filename', '').lower(): + search_name = meta['filename'].lower() + else: + search_name = re.sub(r"[^0-9a-zA-Z\[\\]]+", "", tmdb_name.lower().replace(' ', '')) + if media not in (None, []): + result = {'title': {}} + difference = 0 + for anime in media: + for title in anime['title'].values(): + if title is not None: + title = re.sub(u'[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf\u3400-\u4dbf]+ (?=[A-Za-z ]+–)', "", title.lower().replace(' ', ''), re.U) + diff = SequenceMatcher(None, title, search_name).ratio() + if diff >= difference: + result = anime + difference = diff + + romaji = result['title'].get('romaji', result['title'].get('english', "")) + mal_id = result.get('idMal', 0) + eng_title = result['title'].get('english', result['title'].get('romaji', "")) + season_year = result.get('season_year', "") + episodes = result.get('episodes', 0) + else: + romaji = eng_title = season_year = "" + episodes = mal_id = 0 + if mal_id in [None, 0]: + mal_id = mal + if not episodes: + episodes = 0 + return romaji, mal_id, eng_title, season_year, episodes, demographic + + +async def get_tmdb_imdb_from_mediainfo(mediainfo, category, is_disc, tmdbid, imdbid, tvdbid): + if not is_disc: + if mediainfo['media']['track'][0].get('extra'): + extra = mediainfo['media']['track'][0]['extra'] + for each in extra: + try: + if each.lower().startswith('tmdb') and not tmdbid: + category, tmdbid = parser.parse_tmdb_id(id=extra[each], category=category) + if each.lower().startswith('imdb') and not imdbid: + try: + imdb_id = extract_imdb_id(extra[each]) + if imdb_id: + imdbid = imdb_id + except Exception: + pass + if each.lower().startswith('tvdb') and not tvdbid: + try: + tvdb_id = int(extra[each]) + if tvdb_id: + tvdbid = tvdb_id + except Exception: + pass + except Exception: + pass + + return category, tmdbid, imdbid, tvdbid + + +def extract_imdb_id(value): + """Extract IMDb ID from various formats""" + patterns = [ + r'/title/(tt\d+)', # URL format + r'^(tt\d+)$', # Direct tt format + r'^(\d+)$' # Plain number + ] + + for pattern in patterns: + match = re.search(pattern, value) + if match: + imdb_id = match.group(1) + if not imdb_id.startswith('tt'): + imdb_id = f"tt{imdb_id}" + return int(imdb_id.replace('tt', '')) + + return None + + +async def daily_to_tmdb_season_episode(tmdbid, date): + date = datetime.fromisoformat(str(date)) + + async with httpx.AsyncClient() as client: + # Get TV show information to get seasons + response = await client.get( + f"{TMDB_BASE_URL}/tv/{tmdbid}", + params={"api_key": TMDB_API_KEY} + ) + try: + response.raise_for_status() + tv_data = response.json() + seasons = tv_data.get('seasons', []) + except Exception: + console.print(f"[bold red]Failed to fetch TV data: {response.status_code}[/bold red]") + return 0, 0 + + # Find the latest season that aired before or on the target date + season = 1 + for each in seasons: + if not each.get('air_date'): + continue + + air_date = datetime.fromisoformat(each['air_date']) + if air_date <= date: + season = int(each['season_number']) + + # Get the specific season information + season_response = await client.get( + f"{TMDB_BASE_URL}/tv/{tmdbid}/season/{season}", + params={"api_key": TMDB_API_KEY} + ) + try: + season_response.raise_for_status() + season_data = season_response.json() + season_info = season_data.get('episodes', []) + except Exception: + console.print(f"[bold red]Failed to fetch season data: {season_response.status_code}[/bold red]") + return 0, 0 + + # Find the episode that aired on the target date + episode = 1 + for each in season_info: + if str(each.get('air_date', '')) == str(date.date()): + episode = int(each['episode_number']) + break + else: + console.print(f"[yellow]Unable to map the date ([bold yellow]{str(date)}[/bold yellow]) to a Season/Episode number") + + return season, episode + + +async def get_episode_details(tmdb_id, season_number, episode_number, debug=False): + async with httpx.AsyncClient() as client: + try: + # Get episode details + response = await client.get( + f"{TMDB_BASE_URL}/tv/{tmdb_id}/season/{season_number}/episode/{episode_number}", + params={"api_key": TMDB_API_KEY, "append_to_response": "images,credits,external_ids"} + ) + try: + response.raise_for_status() + episode_data = response.json() + except Exception: + console.print(f"[bold red]Failed to fetch episode data: {response.status_code}[/bold red]") + return {} + + if debug: + console.print(f"[cyan]Episode Data: {json.dumps(episode_data, indent=2)[:600]}...") + + # Extract relevant information + episode_info = { + 'name': episode_data.get('name', ''), + 'overview': episode_data.get('overview', ''), + 'air_date': episode_data.get('air_date', ''), + 'still_path': episode_data.get('still_path', ''), + 'vote_average': episode_data.get('vote_average', 0), + 'episode_number': episode_data.get('episode_number', 0), + 'season_number': episode_data.get('season_number', 0), + 'runtime': episode_data.get('runtime', 0), + 'crew': [], + 'guest_stars': [], + 'director': '', + 'writer': '', + 'imdb_id': episode_data.get('external_ids', {}).get('imdb_id', '') + } + + # Extract crew information + for crew_member in episode_data.get('crew', []): + episode_info['crew'].append({ + 'name': crew_member.get('name', ''), + 'job': crew_member.get('job', ''), + 'department': crew_member.get('department', '') + }) + + # Extract director and writer specifically + if crew_member.get('job') == 'Director': + episode_info['director'] = crew_member.get('name', '') + elif crew_member.get('job') == 'Writer': + episode_info['writer'] = crew_member.get('name', '') + + # Extract guest stars + for guest in episode_data.get('guest_stars', []): + episode_info['guest_stars'].append({ + 'name': guest.get('name', ''), + 'character': guest.get('character', ''), + 'profile_path': guest.get('profile_path', '') + }) + + # Get full image URLs + if episode_info['still_path']: + episode_info['still_url'] = f"https://image.tmdb.org/t/p/original{episode_info['still_path']}" + + return episode_info + + except Exception: + console.print(f"[red]Error fetching episode details for {tmdb_id}[/red]") + console.print(f"[red]Season: {season_number}, Episode: {episode_number}[/red]") + return {} + + +async def get_logo(tmdb_id, category, debug=False, logo_languages=None, TMDB_API_KEY=None, TMDB_BASE_URL=None, logo_json=None): + logo_path = "" + if logo_languages and isinstance(logo_languages, str) and ',' in logo_languages: + logo_languages = [lang.strip() for lang in logo_languages.split(',')] + if debug: + console.print(f"[cyan]Parsed logo languages from comma-separated string: {logo_languages}[/cyan]") + + elif logo_languages is None: + # Get preferred languages in order (from config, then 'en' as fallback) + logo_languages = [config['DEFAULT'].get('logo_language', 'en'), 'en'] + elif isinstance(logo_languages, str): + logo_languages = [logo_languages, 'en'] + + # Remove duplicates while preserving order + logo_languages = list(dict.fromkeys(logo_languages)) + + if debug: + console.print(f"[cyan]Looking for logos in languages (in order): {logo_languages}[/cyan]") + + try: + # Use provided logo_json if available, otherwise fetch it + image_data = None + if logo_json: + image_data = logo_json + if debug: + console.print("[cyan]Using provided logo_json data instead of making an HTTP request[/cyan]") + else: + # Make HTTP request only if logo_json is not provided + async with httpx.AsyncClient() as client: + endpoint = "tv" if category == "TV" else "movie" + image_response = await client.get( + f"{TMDB_BASE_URL}/{endpoint}/{tmdb_id}/images", + params={"api_key": TMDB_API_KEY} + ) + try: + image_response.raise_for_status() + image_data = image_response.json() + except Exception: + console.print(f"[bold red]Failed to fetch image data: {image_response.status_code}[/bold red]") + return "" + + if debug and image_data: + console.print(f"[cyan]Image Data: {json.dumps(image_data, indent=2)[:500]}...") + + logos = image_data.get('logos', []) + + # Only look for logos that match our specified languages + for language in logo_languages: + matching_logo = next((logo for logo in logos if logo.get('iso_639_1') == language), "") + if matching_logo: + logo_path = f"https://image.tmdb.org/t/p/original{matching_logo['file_path']}" + if debug: + console.print(f"[cyan]Found logo in language '{language}': {logo_path}[/cyan]") + break + + # fallback to getting logo with null language if no match found, especially useful for movies it seems + if not logo_path: + null_language_logo = next((logo for logo in logos if logo.get('iso_639_1') is None or logo.get('iso_639_1') == ''), None) + if null_language_logo: + logo_path = f"https://image.tmdb.org/t/p/original{null_language_logo['file_path']}" + if debug: + console.print(f"[cyan]Found logo with null language: {logo_path}[/cyan]") + + if not logo_path and debug: + console.print("[yellow]No suitable logo found in preferred languages or null language[/yellow]") + + except Exception as e: + console.print(f"[red]Error fetching logo: {e}[/red]") + + return logo_path + + +async def get_tmdb_translations(tmdb_id, category, target_language='en', debug=False): + """Get translations from TMDb API""" + endpoint = "movie" if category == "MOVIE" else "tv" + url = f"{TMDB_BASE_URL}/{endpoint}/{tmdb_id}/translations" + + async with httpx.AsyncClient() as client: + try: + response = await client.get(url, params={"api_key": TMDB_API_KEY}) + response.raise_for_status() + data = response.json() + + # Look for target language translation + for translation in data.get('translations', []): + if translation.get('iso_639_1') == target_language: + translated_data = translation.get('data', {}) + translated_title = translated_data.get('title') or translated_data.get('name') + + if translated_title and debug: + console.print(f"[cyan]Found TMDb translation: '{translated_title}'[/cyan]") + + return translated_title or "" + + if debug: + console.print(f"[yellow]No {target_language} translation found in TMDb[/yellow]") + return "" + + except Exception as e: + if debug: + console.print(f"[yellow]TMDb translation fetch failed: {e}[/yellow]") + return "" + + +async def set_tmdb_metadata(meta, filename=None): + if not meta.get('edit', False): + # if we have these fields already, we probably got them from a multi id searching + # and don't need to fetch them again + essential_fields = ['title', 'year', 'genres', 'overview'] + tmdb_metadata_populated = all(meta.get(field) is not None for field in essential_fields) + else: + # if we're in that blasted edit mode, ignore any previous set data and get fresh + tmdb_metadata_populated = False + + if not tmdb_metadata_populated: + max_attempts = 2 + delay_seconds = 5 + for attempt in range(1, max_attempts + 1): + try: + tmdb_metadata = await tmdb_other_meta( + tmdb_id=meta['tmdb_id'], + path=meta.get('path'), + search_year=meta.get('search_year'), + category=meta.get('category'), + imdb_id=meta.get('imdb_id', 0), + manual_language=meta.get('manual_language'), + anime=meta.get('anime', False), + mal_manual=meta.get('mal_manual'), + aka=meta.get('aka', ''), + original_language=meta.get('original_language'), + poster=meta.get('poster'), + debug=meta.get('debug', False), + mode=meta.get('mode', 'cli'), + tvdb_id=meta.get('tvdb_id', 0), + quickie_search=meta.get('quickie_search', False), + filename=filename, + ) + + if tmdb_metadata and all(tmdb_metadata.get(field) for field in ['title', 'year']): + meta.update(tmdb_metadata) + if meta.get('retrieved_aka', None) is not None: + meta['aka'] = meta['retrieved_aka'] + break + else: + error_msg = f"Failed to retrieve essential metadata from TMDB ID: {meta['tmdb_id']}" + if meta['debug']: + console.print(f"[bold red]{error_msg}[/bold red]") + if attempt < max_attempts: + console.print(f"[yellow]Retrying TMDB metadata fetch in {delay_seconds} seconds... (Attempt {attempt + 1}/{max_attempts})[/yellow]") + await asyncio.sleep(delay_seconds) + else: + raise ValueError(error_msg) + except Exception as e: + error_msg = f"TMDB metadata retrieval failed for ID {meta['tmdb_id']}: {str(e)}" + if meta['debug']: + console.print(f"[bold red]{error_msg}[/bold red]") + if attempt < max_attempts: + console.print(f"[yellow]Retrying TMDB metadata fetch in {delay_seconds} seconds... (Attempt {attempt + 1}/{max_attempts})[/yellow]") + await asyncio.sleep(delay_seconds) + else: + console.print(f"[red]Catastrophic error getting TMDB data using ID {meta['tmdb_id']}[/red]") + console.print(f"[red]Check category is set correctly, UA was using {meta.get('category', None)}[/red]") + raise RuntimeError(error_msg) from e + + +async def get_tmdb_localized_data(meta, data_type, language, append_to_response): + endpoint = None + if data_type == 'main': + endpoint = f'/{meta["category"].lower()}/{meta["tmdb"]}' + elif data_type == 'season': + season = meta.get('season_int') + if season is None: + return None + endpoint = f'/tv/{meta["tmdb"]}/season/{season}' + elif data_type == 'episode': + season = meta.get('season_int') + episode = meta.get('episode_int') + if season is None or episode is None: + return None + endpoint = f'/tv/{meta["tmdb"]}/season/{season}/episode/{episode}' + + url = f'{TMDB_BASE_URL}{endpoint}' + params = { + 'api_key': TMDB_API_KEY, + 'language': language + } + if append_to_response: + params.update({'append_to_response': append_to_response}) + + if meta.get('debug', False): + console.print( + '[green]Requesting localized data from TMDB.\n' + f"Type: '{data_type}'.\n" + f"Language: '{language}'\n" + f"Append to response: '{append_to_response}'\n" + f"Endpoint: '{endpoint}'[/green]\n" + ) + + save_dir = f"{meta['base_dir']}/tmp/{meta['uuid']}/" + filename = f"{save_dir}tmdb_localized_data.json" + localized_data = {} + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url, params=params) + if response.status_code == 200: + data = response.json() + + if os.path.exists(filename): + with open(filename, 'r', encoding='utf-8') as f: + localized_data = json.load(f) + else: + localized_data = {} + + localized_data.setdefault(language, {})[data_type] = data + + with open(filename, 'w', encoding='utf-8') as f: + json.dump(localized_data, f, ensure_ascii=False, indent=4) + + return data + else: + print(f'Error fetching {url}: Status {response.status_code}') + return None + except httpx.RequestError as e: + print(f'Request failed for {url}: {e}') + return None diff --git a/src/torrentcreate.py b/src/torrentcreate.py new file mode 100644 index 000000000..6f41e97aa --- /dev/null +++ b/src/torrentcreate.py @@ -0,0 +1,581 @@ +from datetime import datetime +import torf +from torf import Torrent +import random +import math +import os +import re +import cli_ui +import fnmatch +import time +import subprocess +import sys +import platform +import glob +from src.console import console + + +def calculate_piece_size(total_size, min_size, max_size, files, meta): + # Set max_size + if 'max_piece_size' in meta and meta['max_piece_size']: + try: + max_size = min(int(meta['max_piece_size']) * 1024 * 1024, torf.Torrent.piece_size_max) + except ValueError: + max_size = 134217728 # Fallback to default if conversion fails + else: + max_size = 134217728 # 128 MiB default maximum + + if meta.get('debug'): + console.print(f"Content size: {total_size / (1024*1024):.2f} MiB") + console.print(f"Max size: {max_size}") + + total_size_mib = total_size / (1024*1024) + + if total_size_mib <= 60: # <= 60 MiB + piece_size = 32 * 1024 # 32 KiB + elif total_size_mib <= 120: # <= 120 MiB + piece_size = 64 * 1024 # 64 KiB + elif total_size_mib <= 240: # <= 240 MiB + piece_size = 128 * 1024 # 128 KiB + elif total_size_mib <= 480: # <= 480 MiB + piece_size = 256 * 1024 # 256 KiB + elif total_size_mib <= 960: # <= 960 MiB + piece_size = 512 * 1024 # 512 KiB + elif total_size_mib <= 1920: # <= 1.875 GiB + piece_size = 1024 * 1024 # 1 MiB + elif total_size_mib <= 3840: # <= 3.75 GiB + piece_size = 2 * 1024 * 1024 # 2 MiB + elif total_size_mib <= 7680: # <= 7.5 GiB + piece_size = 4 * 1024 * 1024 # 4 MiB + elif total_size_mib <= 15360: # <= 15 GiB + piece_size = 8 * 1024 * 1024 # 8 MiB + elif total_size_mib <= 46080: # <= 45 GiB + piece_size = 16 * 1024 * 1024 # 16 MiB + elif total_size_mib <= 92160: # <= 90 GiB + piece_size = 32 * 1024 * 1024 # 32 MiB + elif total_size_mib <= 138240: # <= 135 GiB + piece_size = 64 * 1024 * 1024 + else: + piece_size = 128 * 1024 * 1024 # 128 MiB + + # Enforce minimum and maximum limits + piece_size = max(min_size, min(piece_size, max_size)) + + # Calculate number of pieces for debugging + num_pieces = math.ceil(total_size / piece_size) + if meta.get('debug'): + console.print(f"Selected piece size: {piece_size / 1024:.2f} KiB") + console.print(f"Number of pieces: {num_pieces}") + + return piece_size + + +class CustomTorrent(torf.Torrent): + # Default piece size limits + torf.Torrent.piece_size_min = 32768 # 32 KiB + torf.Torrent.piece_size_max = 134217728 + + def __init__(self, meta, *args, **kwargs): + self._meta = meta + + # Extract and store the precalculated piece size + self._precalculated_piece_size = kwargs.pop('piece_size', None) + super().__init__(*args, **kwargs) + + # Set piece size directly + if self._precalculated_piece_size is not None: + self._piece_size = self._precalculated_piece_size + self.metainfo['info']['piece length'] = self._precalculated_piece_size + + @property + def piece_size(self): + return self._piece_size + + @piece_size.setter + def piece_size(self, value): + if value is None and self._precalculated_piece_size is not None: + value = self._precalculated_piece_size + + self._piece_size = value + self.metainfo['info']['piece length'] = value + + def validate_piece_size(self, meta=None): + if hasattr(self, '_precalculated_piece_size') and self._precalculated_piece_size is not None: + self._piece_size = self._precalculated_piece_size + self.metainfo['info']['piece length'] = self._precalculated_piece_size + return + + +def build_mkbrr_exclude_string(root_folder, filelist): + manual_patterns = ["*.nfo", "*.jpg", "*.png", '*.srt', '*.sub', '*.vtt', '*.ssa', '*.ass', "*.txt", "*.xml"] + keep_set = set(os.path.abspath(f) for f in filelist) + + exclude_files = set() + for dirpath, _, filenames in os.walk(root_folder): + for fname in filenames: + full_path = os.path.abspath(os.path.join(dirpath, fname)) + if full_path in keep_set: + continue + if any(fnmatch.fnmatch(fname, pat) for pat in manual_patterns): + continue + exclude_files.add(fname) + + exclude_str = ",".join(sorted(exclude_files) + manual_patterns) + return exclude_str + + +def create_torrent(meta, path, output_filename, tracker_url=None): + if meta['isdir']: + if meta['keep_folder']: + cli_ui.info('--keep-folder was specified. Using complete folder for torrent creation.') + if not meta.get('tv_pack', False): + folder_name = os.path.basename(str(path)) + include = [ + f"{folder_name}/{os.path.basename(f)}" + for f in meta['filelist'] + ] + exclude = ["*", "*/**"] + else: + if meta.get('is_disc', False): + path = path + include = [] + exclude = [] + elif not meta.get('tv_pack', False): + os.chdir(path) + globs = glob.glob1(path, "*.mkv") + glob.glob1(path, "*.mp4") + glob.glob1(path, "*.ts") + no_sample_globs = [ + os.path.abspath(f"{path}{os.sep}{file}") for file in globs + if not file.lower().endswith('sample.mkv') or "!sample" in file.lower() + ] + if len(no_sample_globs) == 1: + path = meta['filelist'][0] + exclude = ["*.*", "*sample.mkv", "!sample*.*"] if not meta['is_disc'] else "" + include = ["*.mkv", "*.mp4", "*.ts"] if not meta['is_disc'] else "" + else: + folder_name = os.path.basename(str(path)) + include = [ + f"{folder_name}/{os.path.basename(f)}" + for f in meta['filelist'] + ] + exclude = ["*", "*/**"] + else: + exclude = ["*.*", "*sample.mkv", "!sample*.*"] if not meta['is_disc'] else "" + include = ["*.mkv", "*.mp4", "*.ts"] if not meta['is_disc'] else "" + + if meta['category'] == "TV" and meta.get('tv_pack'): + completeness = check_season_pack_completeness(meta) + + if not completeness['complete']: + just_go = False + try: + missing_list = [f"S{s:02d}E{e:02d}" for s, e in completeness['missing_episodes']] + except ValueError: + console.print("[red]Error determining missing episodes, you should double check the pack manually.") + time.sleep(5) + missing_list = ["Unknown"] + if 'Unknown' not in missing_list: + console.print("[red]Warning: Season pack appears incomplete!") + console.print(f"[yellow]Missing episodes: {', '.join(missing_list)}") + + # Show first 15 files from filelist + filelist = meta['filelist'] + files_shown = 0 + batch_size = 15 + + console.print(f"[cyan]Filelist ({len(filelist)} files):") + for i, file in enumerate(filelist[:batch_size]): + console.print(f"[cyan] {i+1:2d}. {os.path.basename(file)}") + + files_shown = min(batch_size, len(filelist)) + + # Loop to handle showing more files in batches + while files_shown < len(filelist) and not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + remaining_files = len(filelist) - files_shown + console.print(f"[yellow]... and {remaining_files} more files") + + if remaining_files > batch_size: + response = input(f"Show (n)ext {batch_size} files, (a)ll remaining files, (c)ontinue with incomplete pack, or (q)uit? (n/a/c/Q): ") + else: + response = input(f"Show (a)ll remaining {remaining_files} files, (c)ontinue with incomplete pack, or (q)uit? (a/c/Q): ") + + if response.lower() == 'n' and remaining_files > batch_size: + # Show next batch of files + next_batch = filelist[files_shown:files_shown + batch_size] + for i, file in enumerate(next_batch): + console.print(f"[cyan] {files_shown + i + 1:2d}. {os.path.basename(file)}") + files_shown += len(next_batch) + elif response.lower() == 'a': + # Show all remaining files + remaining_batch = filelist[files_shown:] + for i, file in enumerate(remaining_batch): + console.print(f"[cyan] {files_shown + i + 1:2d}. {os.path.basename(file)}") + files_shown = len(filelist) + elif response.lower() == 'c': + just_go = True + break # Continue with incomplete pack + else: # 'q' or any other input + console.print("[red]Aborting torrent creation due to incomplete season pack") + sys.exit(1) + + # Final confirmation if not in unattended mode + if not meta['unattended'] and not just_go or (meta['unattended'] and meta.get('unattended_confirm', False) and not just_go): + response = input("Continue with incomplete season pack? (y/N): ") + if response.lower() != 'y': + console.print("[red]Aborting torrent creation due to incomplete season pack") + sys.exit(1) + else: + if meta['debug']: + console.print("[green]Season pack completeness verified") + + # If using mkbrr, run the external application + if meta.get('mkbrr'): + try: + mkbrr_binary = get_mkbrr_path(meta) + output_path = os.path.join(meta['base_dir'], "tmp", meta['uuid'], f"{output_filename}.torrent") + + # Ensure executable permission for non-Windows systems + if not sys.platform.startswith("win"): + os.chmod(mkbrr_binary, 0o755) + + cmd = [mkbrr_binary, "create", path] + + if tracker_url is not None: + cmd.extend(["-t", tracker_url]) + + if int(meta.get('randomized', 0)) >= 1: + cmd.extend(["-e"]) + + if meta.get('max_piece_size') and tracker_url is None: + try: + max_size_bytes = int(meta['max_piece_size']) * 1024 * 1024 + + # Calculate the appropriate power of 2 (log2) + # We want the largest power of 2 that's less than or equal to max_size_bytes + import math + power = min(27, max(16, math.floor(math.log2(max_size_bytes)))) + + cmd.extend(["-l", str(power)]) + console.print(f"[yellow]Setting mkbrr piece length to 2^{power} ({(2**power) / (1024 * 1024):.2f} MiB)") + except (ValueError, TypeError): + console.print("[yellow]Warning: Invalid max_piece_size value, using default piece length") + + if meta.get('mkbrr_threads') != '0': + cmd.extend(["--workers", meta['mkbrr_threads']]) + + if not meta.get('is_disc', False): + exclude_str = build_mkbrr_exclude_string(str(path), meta['filelist']) + cmd.extend(["--exclude", exclude_str]) + + cmd.extend(["-o", output_path]) + if meta['debug']: + console.print(f"[cyan]mkbrr cmd: {cmd}") + + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, bufsize=1) + + total_pieces = 100 # Default to 100% for scaling progress + pieces_done = 0 + mkbrr_start_time = time.time() + + for line in process.stdout: + line = line.strip() + + # Detect hashing progress, speed, and percentage + match = re.search(r"Hashing pieces.*?\[(\d+(?:\.\d+)? (?:G|M)(?:B|iB)/s)\]\s+(\d+)%", line) + if match: + speed = match.group(1) # Extract speed (e.g., "1.7 GiB/s") + pieces_done = int(match.group(2)) # Extract percentage (e.g., "14") + + # Try to extract the ETA directly if it's in the format [elapsed:remaining] + eta_match = re.search(r'\[(\d+)s:(\d+)s\]', line) + if eta_match: + eta_seconds = int(eta_match.group(2)) + eta = time.strftime("%M:%S", time.gmtime(eta_seconds)) + else: + # Fallback to calculating ETA if not directly available + elapsed_time = time.time() - mkbrr_start_time + if pieces_done > 0: + estimated_total_time = elapsed_time / (pieces_done / 100) + eta_seconds = max(0, estimated_total_time - elapsed_time) + eta = time.strftime("%M:%S", time.gmtime(eta_seconds)) + else: + eta = "--:--" # Placeholder if we can't estimate yet + + cli_ui.info_progress(f"mkbrr hashing... {speed} | ETA: {eta}", pieces_done, total_pieces) + + # Detect final output line + if "Wrote" in line and ".torrent" in line and meta['debug']: + console.print(f"[bold cyan]{line}") # Print the final torrent file creation message + + # Wait for the process to finish + result = process.wait() + + # Verify the torrent was actually created + if result != 0: + console.print(f"[bold red]mkbrr exited with non-zero status code: {result}") + raise RuntimeError(f"mkbrr exited with status code {result}") + + if not os.path.exists(output_path): + console.print("[bold red]mkbrr did not create a torrent file!") + raise FileNotFoundError(f"Expected torrent file {output_path} was not created") + else: + return output_path + + except subprocess.CalledProcessError as e: + console.print(f"[bold red]Error creating torrent with mkbrr: {e}") + console.print("[yellow]Falling back to CustomTorrent method") + meta['mkbrr'] = False + except Exception as e: + console.print(f"[bold red]Error using mkbrr: {str(e)}") + raise sys.exit(1) + + overall_start_time = time.time() + initial_size = 0 + if os.path.isfile(path): + initial_size = os.path.getsize(path) + elif os.path.isdir(path): + for root, dirs, files in os.walk(path): + initial_size += sum(os.path.getsize(os.path.join(root, f)) for f in files if os.path.isfile(os.path.join(root, f))) + + piece_size = calculate_piece_size(initial_size, 32768, 134217728, [], meta) + + # Fallback to CustomTorrent if mkbrr is not used + torrent = CustomTorrent( + meta=meta, + path=path, + trackers=["https://fake.tracker"], + source="UA", + private=True, + exclude_globs=exclude or [], + include_globs=include or [], + creation_date=datetime.now(), + comment="Created by Upload Assistant", + created_by="Upload Assistant", + piece_size=piece_size + ) + + torrent.generate(callback=torf_cb, interval=5) + torrent.write(f"{meta['base_dir']}/tmp/{meta['uuid']}/{output_filename}.torrent", overwrite=True) + torrent.verify_filesize(path) + + total_elapsed_time = time.time() - overall_start_time + formatted_time = time.strftime("%H:%M:%S", time.gmtime(total_elapsed_time)) + + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/{output_filename}.torrent" + torrent_file_size = os.path.getsize(torrent_file_path) / 1024 + if meta['debug']: + console.print() + console.print(f"[bold green]torrent created in {formatted_time}") + console.print(f"[green]Torrent file size: {torrent_file_size:.2f} KB") + return torrent + + +torf_start_time = time.time() + + +def torf_cb(torrent, filepath, pieces_done, pieces_total): + global torf_start_time + + if pieces_done == 0: + torf_start_time = time.time() # Reset start time when hashing starts + + elapsed_time = time.time() - torf_start_time + + # Calculate percentage done + if pieces_total > 0: + percentage_done = (pieces_done / pieces_total) * 100 + else: + percentage_done = 0 + + # Estimate ETA (if at least one piece is done) + if pieces_done > 0: + estimated_total_time = elapsed_time / (pieces_done / pieces_total) + eta_seconds = max(0, estimated_total_time - elapsed_time) + eta = time.strftime("%M:%S", time.gmtime(eta_seconds)) + else: + eta = "--:--" + + # Calculate hashing speed (MB/s) + if elapsed_time > 0 and pieces_done > 0: + piece_size = torrent.piece_size / (1024 * 1024) + speed = (pieces_done * piece_size) / elapsed_time + speed_str = f"{speed:.2f} MB/s" + else: + speed_str = "-- MB/s" + + # Display progress with percentage, speed, and ETA + cli_ui.info_progress(f"Hashing... {speed_str} | ETA: {eta}", int(percentage_done), 100) + + +def create_random_torrents(base_dir, uuid, num, path): + manual_name = re.sub(r"[^0-9a-zA-Z\[\]\'\-]+", ".", os.path.basename(path)) + base_torrent = Torrent.read(f"{base_dir}/tmp/{uuid}/BASE.torrent") + for i in range(1, int(num) + 1): + new_torrent = base_torrent + new_torrent.metainfo['info']['entropy'] = random.randint(1, 999999) + Torrent.copy(new_torrent).write(f"{base_dir}/tmp/{uuid}/[RAND-{i}]{manual_name}.torrent", overwrite=True) + + +async def create_base_from_existing_torrent(torrentpath, base_dir, uuid): + if os.path.exists(torrentpath): + base_torrent = Torrent.read(torrentpath) + base_torrent.trackers = ['https://fake.tracker'] + base_torrent.comment = "Created by Upload Assistant" + base_torrent.created_by = "Created by Upload Assistant" + info_dict = base_torrent.metainfo['info'] + valid_keys = ['name', 'piece length', 'pieces', 'private', 'source'] + + # Add the correct key based on single vs multi file torrent + if 'files' in info_dict: + valid_keys.append('files') + elif 'length' in info_dict: + valid_keys.append('length') + + # Remove everything not in the whitelist + for each in list(info_dict): + if each not in valid_keys: + info_dict.pop(each, None) + for each in list(base_torrent.metainfo): + if each not in ('announce', 'comment', 'creation date', 'created by', 'encoding', 'info'): + base_torrent.metainfo.pop(each, None) + base_torrent.source = 'L4G' + base_torrent.private = True + Torrent.copy(base_torrent).write(f"{base_dir}/tmp/{uuid}/BASE.torrent", overwrite=True) + + +def get_mkbrr_path(meta): + """Determine the correct mkbrr binary based on OS and architecture.""" + base_dir = os.path.join(meta['base_dir'], "bin", "mkbrr") + + # Detect OS & Architecture + system = platform.system().lower() + arch = platform.machine().lower() + + if system == "windows": + binary_path = os.path.join(base_dir, "windows", "x86_64", "mkbrr.exe") + elif system == "darwin": + if "arm" in arch: + binary_path = os.path.join(base_dir, "macos", "arm64", "mkbrr") + else: + binary_path = os.path.join(base_dir, "macos", "x86_64", "mkbrr") + elif system == "linux": + if "x86_64" in arch: + binary_path = os.path.join(base_dir, "linux", "amd64", "mkbrr") + elif "armv6" in arch: + binary_path = os.path.join(base_dir, "linux", "armv6", "mkbrr") + elif "arm" in arch: + binary_path = os.path.join(base_dir, "linux", "arm", "mkbrr") + elif "aarch64" in arch or "arm64" in arch: + binary_path = os.path.join(base_dir, "linux", "arm64", "mkbrr") + else: + raise Exception("Unsupported Linux architecture") + else: + raise Exception("Unsupported OS") + + if not os.path.exists(binary_path): + raise FileNotFoundError(f"mkbrr binary not found: {binary_path}") + + return binary_path + + +def check_season_pack_completeness(meta): + if not meta.get('tv_pack'): + return {'complete': True, 'missing_episodes': [], 'found_episodes': []} + + files = meta.get('filelist', []) + if not files: + return {'complete': True, 'missing_episodes': [], 'found_episodes': []} + + found_episodes = [] + season_numbers = set() + + # Pattern for standard TV shows: S01E01, S01E01E02 + episode_pattern = r'[Ss](\d{1,2})[Ee](\d{1,3})(?:[Ee](\d{1,3}))?' + + # Pattern for episode-only: E01, E01E02 (without season) + episode_only_pattern = r'\b[Ee](\d{1,3})(?:[Ee](\d{1,3}))?\b' + + # Pattern for anime: " - 43 (1080p)" or "43 (1080p)" or similar + anime_pattern = r'(?:\s-\s)?(\d{1,3})\s*\((?:\d+p|480p|480i|576i|576p|720p|1080i|1080p|2160p)\)' + + for file_path in files: + filename = os.path.basename(file_path) + matches = re.findall(episode_pattern, filename) + + for match in matches: + season_str = match[0] + episode1_str = match[1] + episode2_str = match[2] if match[2] else None + + season_num = int(season_str) + episode1_num = int(episode1_str) + found_episodes.append((season_num, episode1_num)) + season_numbers.add(season_num) + + if episode2_str: + episode2_num = int(episode2_str) + found_episodes.append((season_num, episode2_num)) + + if not matches: + episode_only_matches = re.findall(episode_only_pattern, filename) + for match in episode_only_matches: + episode1_num = int(match[0]) + episode2_num = int(match[1]) if match[1] else None + + season_num = meta.get('season_int', 1) + found_episodes.append((season_num, episode1_num)) + season_numbers.add(season_num) + + if episode2_num: + found_episodes.append((season_num, episode2_num)) + + if not matches and not episode_only_matches: + anime_matches = re.findall(anime_pattern, filename) + for match in anime_matches: + episode_num = int(match) + season_num = meta.get('season_int', 1) + found_episodes.append((season_num, episode_num)) + season_numbers.add(season_num) + + if not found_episodes: + console.print("[red]No episodes found in the season pack files.") + time.sleep(1) + # return true to not annoy the user with bad regex + return {'complete': True, 'missing_episodes': [], 'found_episodes': []} + + # Remove duplicates and sort + found_episodes = sorted(list(set(found_episodes))) + + missing_episodes = [] + + # Check each season for completeness + for season in season_numbers: + season_episodes = [ep for s, ep in found_episodes if s == season] + if not season_episodes: + continue + + min_ep = min(season_episodes) + max_ep = max(season_episodes) + + # Check for missing episodes in the range + for ep_num in range(min_ep, max_ep + 1): + if ep_num not in season_episodes: + missing_episodes.append((season, ep_num)) + + is_complete = len(missing_episodes) == 0 + + result = { + 'complete': is_complete, + 'missing_episodes': missing_episodes, + 'found_episodes': found_episodes, + 'seasons': list(season_numbers) + } + + if meta.get('debug'): + console.print("[cyan]Season pack completeness check:") + console.print(f"[cyan]Found episodes: {found_episodes}") + if missing_episodes: + console.print(f"[red]Missing episodes: {missing_episodes}") + else: + console.print("[green]Season pack appears complete") + + return result diff --git a/src/trackerhandle.py b/src/trackerhandle.py new file mode 100644 index 000000000..1eb30dc94 --- /dev/null +++ b/src/trackerhandle.py @@ -0,0 +1,241 @@ +import asyncio +import cli_ui +import sys +import traceback + +from cogs.redaction import redact_private_info +from src.cleanup import cleanup, reset_terminal +from src.manualpackage import package +from src.trackers.COMMON import COMMON +from src.trackers.PTP import PTP +from src.trackers.THR import THR +from src.trackersetup import TRACKER_SETUP + + +async def check_mod_q_and_draft(tracker_class, meta, debug, disctype): + tracker_capabilities = { + 'AITHER': {'mod_q': True, 'draft': False}, + 'BHD': {'draft_live': True}, + 'BLU': {'mod_q': True, 'draft': False}, + 'LST': {'mod_q': True, 'draft': True} + } + + modq, draft = None, None + tracker_caps = tracker_capabilities.get(tracker_class.tracker, {}) + if tracker_class.tracker == 'BHD' and tracker_caps.get('draft_live'): + draft_int = await tracker_class.get_live(meta) + draft = "Draft" if draft_int == 0 else "Live" + + else: + if tracker_caps.get('mod_q'): + modq = await tracker_class.get_flag(meta, 'modq') + modq = 'Yes' if modq else 'No' + if tracker_caps.get('draft'): + draft = await tracker_class.get_flag(meta, 'draft') + draft = 'Yes' if draft else 'No' + + return modq, draft + + +async def process_trackers(meta, config, client, console, api_trackers, tracker_class_map, http_trackers, other_api_trackers): + common = COMMON(config=config) + tracker_setup = TRACKER_SETUP(config=config) + enabled_trackers = tracker_setup.trackers_enabled(meta) + + async def process_single_tracker(tracker): + if not tracker == "MANUAL": + tracker_class = tracker_class_map[tracker](config=config) + if meta['name'].endswith('DUPE?'): + meta['name'] = meta['name'].replace(' DUPE?', '') + + if meta['debug']: + debug = "(DEBUG)" + else: + debug = "" + disctype = meta.get('disctype', None) + tracker = tracker.replace(" ", "").upper().strip() + + if tracker in api_trackers: + tracker_status = meta.get('tracker_status', {}) + upload_status = tracker_status.get(tracker, {}).get('upload', False) + if upload_status: + try: + modq, draft = await check_mod_q_and_draft(tracker_class, meta, debug, disctype) + if modq == "Yes": + console.print(f"(modq: {modq})") + if draft == "Yes": + console.print(f"(draft: {draft})") + try: + await tracker_class.upload(meta, disctype) + except Exception as e: + console.print(f"[red]Upload failed: {e}") + console.print(traceback.format_exc()) + return + except Exception: + console.print(traceback.format_exc()) + return + status = meta.get('tracker_status', {}).get(tracker_class.tracker, {}) + if 'status_message' in status and "data error" not in str(status['status_message']): + await client.add_to_client(meta, tracker_class.tracker) + + elif tracker in other_api_trackers: + tracker_status = meta.get('tracker_status', {}) + upload_status = tracker_status.get(tracker, {}).get('upload', False) + if upload_status: + try: + if tracker == "RTF": + await tracker_class.api_test(meta) + try: + await tracker_class.upload(meta, disctype) + except Exception as e: + console.print(f"[red]Upload failed: {e}") + console.print(traceback.format_exc()) + return + if tracker == 'SN': + await asyncio.sleep(16) + except Exception: + console.print(traceback.format_exc()) + return + status = meta.get('tracker_status', {}).get(tracker_class.tracker, {}) + if 'status_message' in status and "data error" not in str(status['status_message']): + await client.add_to_client(meta, tracker_class.tracker) + + elif tracker in http_trackers: + tracker_status = meta.get('tracker_status', {}) + upload_status = tracker_status.get(tracker, {}).get('upload', False) + if upload_status: + try: + if tracker == "AR": + await tracker_class.validate_credentials(meta) is True + try: + await tracker_class.upload(meta, disctype) + except Exception as e: + console.print(f"[red]Upload failed: {e}") + console.print(traceback.format_exc()) + return + + except Exception: + console.print(traceback.format_exc()) + return + status = meta.get('tracker_status', {}).get(tracker_class.tracker, {}) + if 'status_message' in status and "data error" not in str(status['status_message']): + await client.add_to_client(meta, tracker_class.tracker) + + elif tracker == "MANUAL": + if meta['unattended']: + do_manual = True + else: + try: + do_manual = cli_ui.ask_yes_no("Get files for manual upload?", default=True) + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + if do_manual: + for manual_tracker in enabled_trackers: + if manual_tracker != 'MANUAL': + manual_tracker = manual_tracker.replace(" ", "").upper().strip() + tracker_class = tracker_class_map[manual_tracker](config=config) + if manual_tracker in api_trackers: + await common.unit3d_edit_desc(meta, tracker_class.tracker, tracker_class.signature) + else: + await tracker_class.edit_desc(meta) + url = await package(meta) + if url is False: + console.print(f"[yellow]Unable to upload prep files, they can be found at `tmp/{meta['uuid']}") + else: + console.print(f"[green]{meta['name']}") + console.print(f"[green]Files can be found at: [yellow]{url}[/yellow]") + + elif tracker == "THR": + tracker_status = meta.get('tracker_status', {}) + upload_status = tracker_status.get(tracker, {}).get('upload', False) + if upload_status: + thr = THR(config=config) + try: + await thr.upload(meta, disctype) + except Exception as e: + console.print(f"[red]Upload failed: {e}") + console.print(traceback.format_exc()) + return + await client.add_to_client(meta, "THR") + + elif tracker == "PTP": + tracker_status = meta.get('tracker_status', {}) + upload_status = tracker_status.get(tracker, {}).get('upload', False) + if upload_status: + try: + ptp = PTP(config=config) + groupID = meta.get('ptp_groupID', None) + ptpUrl, ptpData = await ptp.fill_upload_form(groupID, meta) + try: + await ptp.upload(meta, ptpUrl, ptpData, disctype) + await asyncio.sleep(5) + except Exception as e: + console.print(f"[red]Upload failed: {e}") + console.print(traceback.format_exc()) + return + await client.add_to_client(meta, "PTP") + except Exception: + console.print(traceback.format_exc()) + return + + multi_screens = int(config['DEFAULT'].get('multiScreens', 2)) + discs = meta.get('discs', []) + one_disc = True + if discs and len(discs) == 1: + one_disc = True + elif discs and len(discs) > 1: + one_disc = False + + if (not meta.get('tv_pack') and one_disc) or multi_screens == 0: + # Run all tracker tasks concurrently + await asyncio.gather(*(process_single_tracker(tracker) for tracker in enabled_trackers)) + else: + # Process each tracker sequentially + for tracker in enabled_trackers: + await process_single_tracker(tracker) + + try: + if meta.get('print_tracker_messages', False): + for tracker, status in meta.get('tracker_status', {}).items(): + try: + if 'status_message' in status: + print(f"{tracker}: {redact_private_info(status['status_message'])}") + except Exception as e: + console.print(f"[red]Error printing {tracker} status message: {e}[/red]") + elif not meta.get('print_tracker_links', True): + console.print("[green]All tracker uploads processed.[/green]") + except Exception as e: + console.print(f"[red]Error printing tracker messages: {e}[/red]") + pass + if meta.get('print_tracker_links', True): + try: + for tracker, status in meta.get('tracker_status', {}).items(): + try: + if tracker == "MTV" and 'status_message' in status and "data error" not in str(status['status_message']): + console.print(f"[green]{str(status['status_message'])}[/green]") + if 'torrent_id' in status: + tracker_class = tracker_class_map[tracker](config=config) + torrent_url = tracker_class.torrent_url + console.print(f"[green]{torrent_url}{status['torrent_id']}[/green]") + else: + if ( + 'status_message' in status + and 'torrent_id' not in status + and "data error" not in str(status['status_message']) + and tracker != "MTV" + ): + print(f"{tracker}: {redact_private_info(status['status_message'])}") + elif 'status_message' in status and "data error" in str(status['status_message']): + console.print(f"[red]{tracker}: {str(status['status_message'])}[/red]") + else: + if 'skipping' in status and not status['skipping']: + console.print(f"[red]{tracker} gave no useful message.") + except Exception as e: + console.print(f"[red]Error printing {tracker} data: {e}[/red]") + console.print("[green]All tracker uploads processed.[/green]") + except Exception as e: + console.print(f"[red]Error in tracker print loop: {e}[/red]") + pass diff --git a/src/trackermeta.py b/src/trackermeta.py new file mode 100644 index 000000000..b6f536873 --- /dev/null +++ b/src/trackermeta.py @@ -0,0 +1,651 @@ +from src.console import console +from src.trackers.COMMON import COMMON +from data.config import config +import aiohttp +import asyncio +import sys +from PIL import Image +import io +from io import BytesIO +import os +import click +from src.btnid import get_bhd_torrents + +# Define expected amount of screenshots from the config +expected_images = int(config['DEFAULT']['screens']) +valid_images = [] + + +async def prompt_user_for_confirmation(message: str) -> bool: + try: + response = input(f"{message} (Y/n): ").strip().lower() + if response in ["y", "yes", ""]: + return True + return False + except EOFError: + sys.exit(1) + + +async def check_images_concurrently(imagelist, meta): + # Ensure meta['image_sizes'] exists + if 'image_sizes' not in meta: + meta['image_sizes'] = {} + + seen_urls = set() + unique_images = [] + + for img in imagelist: + img_url = img.get('raw_url') + if img_url and img_url not in seen_urls: + seen_urls.add(img_url) + unique_images.append(img) + elif img_url: + if meta.get('debug'): + console.print(f"[yellow]Removing duplicate image URL: {img_url}[/yellow]") + + if len(unique_images) < len(imagelist) and meta['debug']: + console.print(f"[yellow]Removed {len(imagelist) - len(unique_images)} duplicate images from the list.[/yellow]") + + # Map fixed resolution names to vertical resolutions + resolution_map = { + '8640p': 8640, + '4320p': 4320, + '2160p': 2160, + '1440p': 1440, + '1080p': 1080, + '1080i': 1080, + '720p': 720, + '576p': 576, + '576i': 576, + '480p': 480, + '480i': 480, + } + + # Get expected vertical resolution + expected_resolution_name = meta.get('resolution', None) + expected_vertical_resolution = resolution_map.get(expected_resolution_name, None) + + # If no valid resolution is found, skip processing + if expected_vertical_resolution is None: + console.print("[red]Meta resolution is invalid or missing. Skipping all images.[/red]") + return [] + + # Function to check each image's URL, host, and log resolution + save_directory = f"{meta['base_dir']}/tmp/{meta['uuid']}" + + timeout = aiohttp.ClientTimeout(total=30, connect=10, sock_connect=10, sock_read=10) + + async def check_and_collect(image_dict): + img_url = image_dict.get('raw_url') + if not img_url: + return None + + if "ptpimg.me" in img_url and img_url.startswith("http://"): + img_url = img_url.replace("http://", "https://") + image_dict['raw_url'] = img_url + image_dict['web_url'] = img_url + + # Handle when pixhost url points to web_url and convert to raw_url + if img_url.startswith("https://pixhost.to/show/"): + img_url = img_url.replace("https://pixhost.to/show/", "https://img1.pixhost.to/images/", 1) + + # Verify the image link + try: + if await check_image_link(img_url, timeout): + try: + async with aiohttp.ClientSession(timeout=timeout) as session: + try: + async with session.get(img_url) as response: + if response.status == 200: + image_content = await response.read() + + try: + image = Image.open(BytesIO(image_content)) + vertical_resolution = image.height + lower_bound = expected_vertical_resolution * 0.70 + upper_bound = expected_vertical_resolution * (1.30 if meta['is_disc'] == "DVD" else 1.00) + + if not (lower_bound <= vertical_resolution <= upper_bound): + console.print( + f"[red]Image {img_url} resolution ({vertical_resolution}p) " + f"is outside the allowed range ({int(lower_bound)}-{int(upper_bound)}p). Skipping.[/red]" + ) + return None + + # Save image + os.makedirs(save_directory, exist_ok=True) + image_filename = os.path.join(save_directory, os.path.basename(img_url)) + with open(image_filename, "wb") as f: + f.write(image_content) + + console.print(f"Saved {img_url} as {image_filename}") + + meta['image_sizes'][img_url] = len(image_content) + + if meta['debug']: + console.print( + f"Valid image {img_url} with resolution {image.width}x{image.height} " + f"and size {len(image_content) / 1024:.2f} KiB" + ) + return image_dict + except Exception as e: + console.print(f"[red]Failed to process image {img_url}: {e}") + return None + else: + console.print(f"[red]Failed to fetch image {img_url}. Status: {response.status}. Skipping.") + return None + except asyncio.TimeoutError: + console.print(f"[red]Timeout downloading image: {img_url}") + return None + except aiohttp.ClientError as e: + console.print(f"[red]Client error downloading image: {img_url} - {e}") + return None + except Exception as e: + console.print(f"[red]Session error for image: {img_url} - {e}") + return None + else: + return None + except Exception as e: + console.print(f"[red]Error checking image: {img_url} - {e}") + return None + + # Run image verification concurrently but with a limit to prevent too many simultaneous connections + semaphore = asyncio.Semaphore(2) # Limit concurrent requests to 2 + + async def bounded_check(image_dict): + async with semaphore: + return await check_and_collect(image_dict) + + tasks = [bounded_check(image_dict) for image_dict in unique_images] + + try: + results = await asyncio.gather(*tasks, return_exceptions=False) + except Exception as e: + console.print(f"[red]Error during image processing: {e}") + results = [] + + # Collect valid images and limit to amount set in config + valid_images = [image for image in results if image is not None] + if expected_images < len(valid_images): + valid_images = valid_images[:expected_images] + + return valid_images + + +async def check_image_link(url, timeout=None): + # Handle when pixhost url points to web_url and convert to raw_url + if url.startswith("https://pixhost.to/show/"): + url = url.replace("https://pixhost.to/show/", "https://img1.pixhost.to/images/", 1) + if timeout is None: + timeout = aiohttp.ClientTimeout(total=20, connect=10, sock_connect=10) + + connector = aiohttp.TCPConnector(ssl=False) # Disable SSL verification for testing + + try: + async with aiohttp.ClientSession(timeout=timeout, connector=connector) as session: + try: + async with session.get(url) as response: + if response.status == 200: + content_type = response.headers.get('Content-Type', '').lower() + if 'image' in content_type: + # Attempt to load the image + image_data = await response.read() + try: + image = Image.open(io.BytesIO(image_data)) + image.verify() # This will check if the image is broken + return True + except (IOError, SyntaxError) as e: + console.print(f"[red]Image verification failed (corrupt image): {url} {e}[/red]") + return False + else: + console.print(f"[red]Content type is not an image: {url}[/red]") + return False + else: + console.print(f"[red]Failed to retrieve image: {url} (status code: {response.status})[/red]") + return False + except asyncio.TimeoutError: + console.print(f"[red]Timeout checking image link: {url}[/red]") + return False + except Exception as e: + console.print(f"[red]Exception occurred while checking image: {url} - {str(e)}[/red]") + return False + except Exception as e: + console.print(f"[red]Session creation failed for: {url} - {str(e)}[/red]") + return False + + +async def update_meta_with_unit3d_data(meta, tracker_data, tracker_name, only_id=False): + # Unpack the expected 9 elements, ignoring any additional ones + tmdb, imdb, tvdb, mal, desc, category, infohash, imagelist, filename, *rest = tracker_data + + if tmdb: + meta['tmdb_id'] = tmdb + if meta['debug']: + console.print("set TMDB ID:", meta['tmdb_id']) + if imdb: + meta['imdb_id'] = int(imdb) + if meta['debug']: + console.print("set IMDB ID:", meta['imdb_id']) + if tvdb: + meta['tvdb_id'] = tvdb + if meta['debug']: + console.print("set TVDB ID:", meta['tvdb_id']) + if mal: + meta['mal_id'] = mal + if meta['debug']: + console.print("set MAL ID:", meta['mal_id']) + if desc and not only_id: + meta['description'] = desc + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'w', newline="", encoding='utf8') as description: + if len(desc) > 0: + description.write((desc or "") + "\n") + if category and not meta.get('manual_category', None): + cat_upper = category.upper() + if "MOVIE" in cat_upper: + meta['category'] = "MOVIE" + elif "TV" in cat_upper: + meta['category'] = "TV" + if meta['debug']: + console.print("set Category:", meta['category']) + + if not meta.get('image_list'): # Only handle images if image_list is not already populated + if imagelist: # Ensure imagelist is not empty before setting + valid_images = await check_images_concurrently(imagelist, meta) + if valid_images: + meta['image_list'] = valid_images + if meta.get('image_list'): # Double-check if image_list is set before handling it + if not (meta.get('blu') or meta.get('aither') or meta.get('lst') or meta.get('oe') or meta.get('huno') or meta.get('ulcx')) or meta['unattended']: + await handle_image_list(meta, tracker_name, valid_images) + + if filename: + meta[f'{tracker_name.lower()}_filename'] = filename + + if meta['debug']: + console.print(f"[green]{tracker_name} data successfully updated in meta[/green]") + + +async def update_metadata_from_tracker(tracker_name, tracker_instance, meta, search_term, search_file_folder, only_id=False): + tracker_key = tracker_name.lower() + manual_key = f"{tracker_key}_manual" + found_match = False + + if tracker_name == "PTP": + imdb_id = 0 + ptp_imagelist = [] + if meta.get('ptp') is None: + imdb_id, ptp_torrent_id, meta['ext_torrenthash'] = await tracker_instance.get_ptp_id_imdb(search_term, search_file_folder, meta) + if ptp_torrent_id: + if imdb_id: + console.print(f"[green]{tracker_name} IMDb ID found: tt{str(imdb_id).zfill(7)}[/green]") + + if not meta['unattended']: + if await prompt_user_for_confirmation("Do you want to use this ID data from PTP?"): + meta['imdb_id'] = imdb_id + found_match = True + meta['ptp'] = ptp_torrent_id + + if not only_id or meta.get('keep_images'): + ptp_imagelist = await tracker_instance.get_ptp_description(ptp_torrent_id, meta, meta.get('is_disc', False)) + if ptp_imagelist: + valid_images = await check_images_concurrently(ptp_imagelist, meta) + if valid_images: + meta['image_list'] = valid_images + await handle_image_list(meta, tracker_name, valid_images) + + else: + found_match = False + meta['imdb_id'] = meta.get('imdb_id') if meta.get('imdb_id') else 0 + meta['ptp'] = None + meta['description'] = "" + meta['image_list'] = [] + + else: + found_match = True + meta['imdb_id'] = imdb_id + if not only_id or meta.get('keep_images'): + ptp_imagelist = await tracker_instance.get_ptp_description(ptp_torrent_id, meta, meta.get('is_disc', False)) + if ptp_imagelist: + valid_images = await check_images_concurrently(ptp_imagelist, meta) + if valid_images: + meta['image_list'] = valid_images + else: + console.print("[yellow]Skipping PTP as no match found[/yellow]") + found_match = False + + else: + ptp_torrent_id = meta['ptp'] + imdb_id, meta['ext_torrenthash'] = await tracker_instance.get_imdb_from_torrent_id(ptp_torrent_id) + if imdb_id: + meta['imdb_id'] = imdb_id + if meta['debug']: + console.print(f"[green]IMDb ID found: tt{str(meta['imdb_id']).zfill(7)}[/green]") + found_match = True + meta['skipit'] = True + if not only_id or meta.get('keep_images'): + ptp_imagelist = await tracker_instance.get_ptp_description(meta['ptp'], meta, meta.get('is_disc', False)) + if ptp_imagelist: + valid_images = await check_images_concurrently(ptp_imagelist, meta) + if valid_images: + meta['image_list'] = valid_images + console.print("[green]PTP images added to metadata.[/green]") + else: + console.print(f"[yellow]Could not find IMDb ID using PTP ID: {ptp_torrent_id}[/yellow]") + found_match = False + + elif tracker_name == "BHD": + bhd_main_api = config['TRACKERS']['BHD'].get('api_key') + bhd_other_api = config['DEFAULT'].get('bhd_api') + if bhd_main_api and len(bhd_main_api) < 25: + bhd_main_api = None + if bhd_other_api and len(bhd_other_api) < 25: + bhd_other_api = None + elif bhd_other_api and len(bhd_other_api) > 25: + console.print("[red]BHD API key is being retired from the DEFAULT config section. Only using api from the BHD tracker section instead.[/red]") + await asyncio.sleep(2) + bhd_api = bhd_main_api if bhd_main_api else bhd_other_api + bhd_main_rss = config['TRACKERS']['BHD'].get('bhd_rss_key') + bhd_other_rss = config['DEFAULT'].get('bhd_rss_key') + if bhd_main_rss and len(bhd_main_rss) < 25: + bhd_main_rss = None + if bhd_other_rss and len(bhd_other_rss) < 25: + bhd_other_rss = None + elif bhd_other_rss and len(bhd_other_rss) > 25: + console.print("[red]BHD RSS key is being retired from the DEFAULT config section. Only using rss key from the BHD tracker section instead.[/red]") + await asyncio.sleep(2) + bhd_rss_key = bhd_main_rss if bhd_main_rss else bhd_other_rss + if not bhd_api or not bhd_rss_key: + console.print("[red]BHD API or RSS key not found. Please check your configuration.[/red]") + return meta, False + use_foldername = (meta.get('is_disc') is not None or + meta.get('keep_folder') is True or + meta.get('isdir') is True) + + if meta.get('bhd'): + await get_bhd_torrents(bhd_api, bhd_rss_key, meta, only_id, torrent_id=meta['bhd']) + elif use_foldername: + # Use folder name from path if available, fall back to UUID + folder_path = meta.get('path', '') + foldername = os.path.basename(folder_path) if folder_path else meta.get('uuid', '') + await get_bhd_torrents(bhd_api, bhd_rss_key, meta, only_id, foldername=foldername) + else: + # Only use filename if none of the folder conditions are met + filename = os.path.basename(meta['filelist'][0]) if meta.get('filelist') else None + await get_bhd_torrents(bhd_api, bhd_rss_key, meta, only_id, filename=filename) + + if meta.get('imdb_id') or meta.get('tmdb_id'): + if not meta['unattended']: + console.print(f"[green]{tracker_name} data found: IMDb ID: {meta.get('imdb_id')}, TMDb ID: {meta.get('tmdb_id')}[/green]") + if await prompt_user_for_confirmation(f"Do you want to use the ID's found on {tracker_name}?"): + found_match = True + if meta.get('description') and meta.get('description') != "": + description = meta.get('description') + console.print("[bold green]Successfully grabbed description from BHD") + console.print(f"Description after cleaning:\n{description[:1000]}...", markup=False) + + if not meta.get('skipit'): + console.print("[cyan]Do you want to edit, discard or keep the description?[/cyan]") + edit_choice = input("Enter 'e' to edit, 'd' to discard, or press Enter to keep it as is: ") + + if edit_choice.lower() == 'e': + edited_description = click.edit(description) + if edited_description: + desc = edited_description.strip() + meta['description'] = desc + meta['saved_description'] = True + console.print(f"[green]Final description after editing:[/green] {meta['description']}", markup=False) + elif edit_choice.lower() == 'd': + meta['description'] = "" + meta['image_list'] = [] + console.print("[yellow]Description discarded.[/yellow]") + else: + console.print("[green]Keeping the original description.[/green]") + meta['description'] = description + meta['saved_description'] = True + else: + meta['description'] = description + meta['saved_description'] = True + elif meta.get('bhd_nfo'): + if not meta.get('skipit'): + nfo_file_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid'], "bhd.nfo") + if os.path.exists(nfo_file_path): + with open(nfo_file_path, 'r', encoding='utf-8') as nfo_file: + nfo_content = nfo_file.read() + console.print("[bold green]Successfully grabbed FraMeSToR description") + console.print(f"Description content:\n{nfo_content[:1000]}...", markup=False) + console.print("[cyan]Do you want to discard or keep the description?[/cyan]") + edit_choice = input("Enter 'd' to discard, or press Enter to keep it as is: ") + + if edit_choice.lower() == 'd': + meta['description'] = "" + meta['image_list'] = [] + nfo_file_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid'], "bhd.nfo") + nfo_file.close() + + try: + import gc + gc.collect() # Force garbage collection to close any lingering handles + for attempt in range(3): + try: + os.remove(nfo_file_path) + console.print("[yellow]NFO file successfully deleted.[/yellow]") + break + except Exception as e: + if attempt < 2: + console.print(f"[yellow]Attempt {attempt+1}: Could not delete file, retrying in 1 second...[/yellow]") + import time + time.sleep(1) + else: + console.print(f"[red]Failed to delete BHD NFO file after 3 attempts: {e}[/red]") + except Exception as e: + console.print(f"[red]Error during file cleanup: {e}[/red]") + meta['nfo'] = False + meta['bhd_nfo'] = False + console.print("[yellow]Description discarded.[/yellow]") + else: + console.print("[green]Keeping the original description.[/green]") + + if meta.get('image_list'): + valid_images = await check_images_concurrently(meta.get('image_list'), meta) + if valid_images: + meta['image_list'] = valid_images + await handle_image_list(meta, tracker_name, valid_images) + else: + meta['image_list'] = [] + + else: + console.print(f"[yellow]{tracker_name} data discarded.[/yellow]") + meta[tracker_key] = None + meta['imdb_id'] = 0 + meta['tmdb_id'] = 0 + meta["framestor"] = False + meta["flux"] = False + meta["description"] = "" + meta["image_list"] = [] + meta['nfo'] = False + meta['bhd_nfo'] = False + save_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid']) + nfo_file_path = os.path.join(save_path, "bhd.nfo") + if os.path.exists(nfo_file_path): + try: + os.remove(nfo_file_path) + except Exception as e: + console.print(f"[red]Failed to delete BHD NFO file: {e}[/red]") + found_match = False + else: + console.print(f"[green]{tracker_name} data found: IMDb ID: {meta.get('imdb_id')}, TMDb ID: {meta.get('tmdb_id')}[/green]") + found_match = True + if meta.get('image_list'): + valid_images = await check_images_concurrently(meta.get('image_list'), meta) + if valid_images: + meta['image_list'] = valid_images + else: + meta['image_list'] = [] + else: + found_match = False + + elif tracker_name in ["HUNO", "BLU", "AITHER", "LST", "OE", "ULCX", "RF", "OTW", "YUS", "DP", "SP"]: + if meta.get(tracker_key) is not None: + if meta['debug']: + console.print(f"[cyan]{tracker_name} ID found in meta, reusing existing ID: {meta[tracker_key]}[/cyan]") + tracker_data = await COMMON(config).unit3d_torrent_info( + tracker_name, + tracker_instance.id_url, + tracker_instance.search_url, + meta, + id=meta[tracker_key], + only_id=only_id + ) + else: + if meta['debug']: + console.print(f"[yellow]No ID found in meta for {tracker_name}, searching by file name[/yellow]") + tracker_data = await COMMON(config).unit3d_torrent_info( + tracker_name, + tracker_instance.id_url, + tracker_instance.search_url, + meta, + file_name=search_term, + only_id=only_id + ) + + if any(item not in [None, 0] for item in tracker_data[:3]): # Check for valid tmdb, imdb, or tvdb + if meta['debug']: + console.print(f"[green]Valid data found on {tracker_name}, setting meta values[/green]") + await update_meta_with_unit3d_data(meta, tracker_data, tracker_name, only_id) + found_match = True + else: + if meta['debug']: + console.print(f"[yellow]No valid data found on {tracker_name}[/yellow]") + found_match = False + + elif tracker_name == "HDB": + from src.bbcode import BBCODE + bbcode = BBCODE() + if meta.get('hdb') is not None: + meta[manual_key] = meta[tracker_key] + console.print(f"[cyan]{tracker_name} ID found in meta, reusing existing ID: {meta[tracker_key]}[/cyan]") + + # Use get_info_from_torrent_id function if ID is found in meta + imdb, tvdb_id, hdb_name, meta['ext_torrenthash'], meta['hdb_description'] = await tracker_instance.get_info_from_torrent_id(meta[tracker_key]) + + if imdb or tvdb_id: + meta['imdb_id'] = imdb if imdb else meta.get('imdb_id', 0) + meta['tvdb_id'] = tvdb_id if tvdb_id else meta.get('tvdb_id', 0) + meta['hdb_name'] = hdb_name + found_match = True + result = bbcode.clean_hdb_description(meta['hdb_description']) + if meta['hdb_description'] and len(meta['hdb_description']) > 0 and not only_id: + if result is None: + console.print("[yellow]Failed to clean HDB description, it might be empty or malformed[/yellow]") + meta['description'] = "" + meta['image_list'] = [] + else: + meta['description'], meta['image_list'] = result + meta['saved_description'] = True + + if meta.get('image_list') and meta.get('keep_images'): + valid_images = await check_images_concurrently(meta.get('image_list'), meta) + if valid_images: + meta['image_list'] = valid_images + await handle_image_list(meta, tracker_name, valid_images) + else: + meta['image_list'] = [] + + console.print(f"[green]{tracker_name} data found: IMDb ID: {imdb}, TVDb ID: {meta['tvdb_id']}, HDB Name: {meta['hdb_name']}[/green]") + else: + console.print(f"[yellow]{tracker_name} data not found for ID: {meta[tracker_key]}[/yellow]") + found_match = False + else: + console.print("[yellow]No ID found in meta for HDB, searching by file name[/yellow]") + + # Use search_filename function if ID is not found in meta + imdb, tvdb_id, hdb_name, meta['ext_torrenthash'], meta['hdb_description'], tracker_id = await tracker_instance.search_filename(search_term, search_file_folder, meta) + meta['hdb_name'] = hdb_name + if tracker_id: + meta[tracker_key] = tracker_id + + if imdb or tvdb_id: + if not meta['unattended']: + console.print(f"[green]{tracker_name} data found: IMDb ID: {imdb}, TVDb ID: {meta['tvdb_id']}, HDB Name: {meta['hdb_name']}[/green]") + if await prompt_user_for_confirmation(f"Do you want to use the ID's found on {tracker_name}?"): + console.print(f"[green]{tracker_name} data retained.[/green]") + meta['imdb_id'] = imdb if imdb else meta.get('imdb_id') + meta['tvdb_id'] = tvdb_id if tvdb_id else meta.get('tvdb_id') + found_match = True + if meta['hdb_description'] and len(meta['hdb_description']) > 0 and not only_id: + result = bbcode.clean_hdb_description(meta['hdb_description']) + if result is None: + console.print("[yellow]Failed to clean HDB description, it might be empty or malformed[/yellow]") + meta['description'] = "" + meta['image_list'] = [] + else: + desc, meta['image_list'] = result + console.print("[bold green]Successfully grabbed description from HDB") + console.print(f"Description content:\n{desc[:1000]}...", markup=False) + console.print("[cyan]Do you want to edit, discard or keep the description?[/cyan]") + edit_choice = input("Enter 'e' to edit, 'd' to discard, or press Enter to keep it as is: ") + + if edit_choice.lower() == 'e': + edited_description = click.edit(desc) + if edited_description: + desc = edited_description.strip() + meta['description'] = desc + meta['saved_description'] = True + console.print(f"[green]Final description after editing:[/green] {desc}", markup=False) + elif edit_choice.lower() == 'd': + meta['description'] = "" + meta['hdb_description'] = "" + console.print("[yellow]Description discarded.[/yellow]") + else: + console.print("[green]Keeping the original description.[/green]") + meta['description'] = desc + meta['saved_description'] = True + if meta.get('image_list') and meta.get('keep_images'): + valid_images = await check_images_concurrently(meta.get('image_list'), meta) + if valid_images: + meta['image_list'] = valid_images + await handle_image_list(meta, tracker_name, valid_images) + else: + console.print(f"[yellow]{tracker_name} data discarded.[/yellow]") + meta[tracker_key] = None + meta['tvdb_id'] = meta.get('tvdb_id') if meta.get('tvdb_id') else 0 + meta['imdb_id'] = meta.get('imdb_id') if meta.get('imdb_id') else 0 + meta['hdb_name'] = None + found_match = False + else: + console.print(f"[green]{tracker_name} data found: IMDb ID: {imdb}, TVDb ID: {meta['tvdb_id']}, HDB Name: {hdb_name}[/green]") + found_match = True + else: + found_match = False + + return meta, found_match + + +async def handle_image_list(meta, tracker_name, valid_images=None): + if meta.get('image_list'): + console.print(f"[cyan]Selected the following {len(valid_images)} valid images from {tracker_name}:") + for img in meta['image_list']: + console.print(f"Image:[green]'{img.get('img_url')}'[/green]") + + if meta['unattended']: + keep_images = True + else: + keep_images = await prompt_user_for_confirmation(f"Do you want to keep the images found on {tracker_name}?") + if not keep_images: + meta['image_list'] = [] + meta['image_sizes'] = {} + save_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid']) + try: + import glob + png_files = glob.glob(os.path.join(save_path, "*.png")) + for png_file in png_files: + os.remove(png_file) + + if png_files: + console.print(f"[yellow]Successfully deleted {len(png_files)} image files.[/yellow]") + else: + console.print("[yellow]No image files found to delete.[/yellow]") + except Exception as e: + console.print(f"[red]Failed to delete image files: {e}[/red]") + console.print(f"[yellow]Images discarded from {tracker_name}.") + else: + console.print(f"[green]Images retained from {tracker_name}.") diff --git a/src/trackers/ACM.py b/src/trackers/ACM.py index 270fd25b3..bfb4a2dee 100644 --- a/src/trackers/ACM.py +++ b/src/trackers/ACM.py @@ -1,48 +1,32 @@ # -*- coding: utf-8 -*- # import discord +import aiofiles import asyncio -import requests -import distutils.util +import bencodepy +import httpx import os -import platform -from src.trackers.COMMON import COMMON +import requests from src.console import console +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D - -class ACM(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - - ############################################################### - ######## EDIT ME ######## - ############################################################### - - # ALSO EDIT CLASS NAME ABOVE - +class ACM(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='ACM') self.config = config + self.common = COMMON(config) self.tracker = 'ACM' self.source_flag = 'AsianCinema' - self.upload_url = 'https://asiancinema.me/api/torrents/upload' - self.search_url = 'https://asiancinema.me/api/torrents/filter' - self.signature = None - self.banned_groups = [""] + self.base_url = 'https://eiga.moi' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] pass - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id - - async def get_type (self, meta): + + async def get_type_id(self, meta): if meta['is_disc'] == "BDMV": bdinfo = meta['bdinfo'] bd_sizes = [25, 50, 66, 100] @@ -60,7 +44,7 @@ async def get_type (self, meta): if "DVD5" in meta['dvd_size']: type_string = "DVD 5" elif "DVD9" in meta['dvd_size']: - type_string = "DVD 9" + type_string = "DVD 9" else: if meta['type'] == "REMUX": if meta['source'] == "BluRay": @@ -73,91 +57,95 @@ async def get_type (self, meta): # acceptable_res = ["2160p", "1080p", "1080i", "720p", "576p", "576i", "540p", "480p", "Other"] # if meta['resolution'] in acceptable_res: # type_id = meta['resolution'] - # else: + # else: # type_id = "Other" - return type_string - async def get_type_id(self, type): - type_id = { - 'UHD 100': '1', + type_id_map = { + 'UHD 100': '1', 'UHD 66': '2', 'UHD 50': '3', 'UHD REMUX': '12', 'BD 50': '4', - 'BD 25': '5', + 'BD 25': '5', 'DVD 5': '14', 'REMUX': '7', 'WEBDL': '9', 'SDTV': '13', 'DVD 9': '16', - 'HDTV': '17' - }.get(type, '0') - return type_id + 'HDTV': '17', + } + type_id = type_id_map.get(type_string, '0') + + return {'type_id': type_id} - async def get_res_id(self, resolution): + async def get_resolution_id(self, meta): resolution_id = { - '2160p': '1', + '2160p': '1', '1080p': '2', - '1080i':'2', - '720p': '3', - '576p': '4', + '1080i': '2', + '720p': '3', + '576p': '4', '576i': '4', - '480p': '5', + '480p': '5', '480i': '5' - }.get(resolution, '10') - return resolution_id + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} + + # ACM rejects uploads with more that 10 keywords + async def get_keywords(self, meta): + keywords = meta.get('keywords', '') + if keywords != '': + keywords_list = keywords.split(',') + keywords_list = [keyword for keyword in keywords_list if " " not in keyword][:10] + keywords = ', '.join(keywords_list) + return {'keywords': keywords} - #ACM rejects uploads with more that 4 keywords - async def get_keywords(self, keywords): - if keywords !='': - keywords_list = keywords.split(',') - keywords_list = [keyword for keyword in keywords_list if " " not in keyword][:4] - keywords = ', '.join( keywords_list) - return keywords + async def get_additional_files(self, meta): + return {} def get_subtitles(self, meta): sub_lang_map = { - ("Arabic", "ara", "ar") : 'Ara', - ("Brazilian Portuguese", "Brazilian", "Portuguese-BR", 'pt-br') : 'Por-BR', - ("Bulgarian", "bul", "bg") : 'Bul', - ("Chinese", "chi", "zh", "Chinese (Simplified)", "Chinese (Traditional)") : 'Chi', - ("Croatian", "hrv", "hr", "scr") : 'Cro', - ("Czech", "cze", "cz", "cs") : 'Cze', - ("Danish", "dan", "da") : 'Dan', - ("Dutch", "dut", "nl") : 'Dut', - ("English", "eng", "en", "English (CC)", "English - SDH") : 'Eng', - ("English - Forced", "English (Forced)", "en (Forced)") : 'Eng', - ("English Intertitles", "English (Intertitles)", "English - Intertitles", "en (Intertitles)") : 'Eng', - ("Estonian", "est", "et") : 'Est', - ("Finnish", "fin", "fi") : 'Fin', - ("French", "fre", "fr") : 'Fre', - ("German", "ger", "de") : 'Ger', - ("Greek", "gre", "el") : 'Gre', - ("Hebrew", "heb", "he") : 'Heb', - ("Hindi" "hin", "hi") : 'Hin', - ("Hungarian", "hun", "hu") : 'Hun', - ("Icelandic", "ice", "is") : 'Ice', - ("Indonesian", "ind", "id") : 'Ind', - ("Italian", "ita", "it") : 'Ita', - ("Japanese", "jpn", "ja") : 'Jpn', - ("Korean", "kor", "ko") : 'Kor', - ("Latvian", "lav", "lv") : 'Lav', - ("Lithuanian", "lit", "lt") : 'Lit', - ("Norwegian", "nor", "no") : 'Nor', - ("Persian", "fa", "far") : 'Per', - ("Polish", "pol", "pl") : 'Pol', - ("Portuguese", "por", "pt") : 'Por', - ("Romanian", "rum", "ro") : 'Rom', - ("Russian", "rus", "ru") : 'Rus', - ("Serbian", "srp", "sr", "scc") : 'Ser', - ("Slovak", "slo", "sk") : 'Slo', - ("Slovenian", "slv", "sl") : 'Slv', - ("Spanish", "spa", "es") : 'Spa', - ("Swedish", "swe", "sv") : 'Swe', - ("Thai", "tha", "th") : 'Tha', - ("Turkish", "tur", "tr") : 'Tur', - ("Ukrainian", "ukr", "uk") : 'Ukr', - ("Vietnamese", "vie", "vi") : 'Vie', + ("Arabic", "ara", "ar"): 'Ara', + ("Brazilian Portuguese", "Brazilian", "Portuguese-BR", 'pt-br'): 'Por-BR', + ("Bulgarian", "bul", "bg"): 'Bul', + ("Chinese", "chi", "zh", "Chinese (Simplified)", "Chinese (Traditional)"): 'Chi', + ("Croatian", "hrv", "hr", "scr"): 'Cro', + ("Czech", "cze", "cz", "cs"): 'Cze', + ("Danish", "dan", "da"): 'Dan', + ("Dutch", "dut", "nl"): 'Dut', + ("English", "eng", "en", "English (CC)", "English - SDH"): 'Eng', + ("English - Forced", "English (Forced)", "en (Forced)"): 'Eng', + ("English Intertitles", "English (Intertitles)", "English - Intertitles", "en (Intertitles)"): 'Eng', + ("Estonian", "est", "et"): 'Est', + ("Finnish", "fin", "fi"): 'Fin', + ("French", "fre", "fr"): 'Fre', + ("German", "ger", "de"): 'Ger', + ("Greek", "gre", "el"): 'Gre', + ("Hebrew", "heb", "he"): 'Heb', + ("Hindi" "hin", "hi"): 'Hin', + ("Hungarian", "hun", "hu"): 'Hun', + ("Icelandic", "ice", "is"): 'Ice', + ("Indonesian", "ind", "id"): 'Ind', + ("Italian", "ita", "it"): 'Ita', + ("Japanese", "jpn", "ja"): 'Jpn', + ("Korean", "kor", "ko"): 'Kor', + ("Latvian", "lav", "lv"): 'Lav', + ("Lithuanian", "lit", "lt"): 'Lit', + ("Norwegian", "nor", "no"): 'Nor', + ("Persian", "fa", "far"): 'Per', + ("Polish", "pol", "pl"): 'Pol', + ("Portuguese", "por", "pt"): 'Por', + ("Romanian", "rum", "ro"): 'Rom', + ("Russian", "rus", "ru"): 'Rus', + ("Serbian", "srp", "sr", "scc"): 'Ser', + ("Slovak", "slo", "sk"): 'Slo', + ("Slovenian", "slv", "sl"): 'Slv', + ("Spanish", "spa", "es"): 'Spa', + ("Swedish", "swe", "sv"): 'Swe', + ("Thai", "tha", "th"): 'Tha', + ("Turkish", "tur", "tr"): 'Tur', + ("Ukrainian", "ukr", "uk"): 'Ukr', + ("Vietnamese", "vie", "vi"): 'Vie', } sub_langs = [] @@ -169,7 +157,8 @@ def get_subtitles(self, meta): if language == "en": if track.get('Forced', "") == "Yes": language = "en (Forced)" - if "intertitles" in track.get('Title', "").lower(): + title = track.get('Title', "") + if isinstance(title, str) and "intertitles" in title.lower(): language = "en (Intertitles)" for lang, subID in sub_lang_map.items(): if language in lang and subID not in sub_langs: @@ -179,12 +168,12 @@ def get_subtitles(self, meta): for lang, subID in sub_lang_map.items(): if language in lang and subID not in sub_langs: sub_langs.append(subID) - - # if sub_langs == []: + + # if sub_langs == []: # sub_langs = [44] # No Subtitle return sub_langs - def get_subs_tag(self, subs): + def get_subs_tag(self, subs): if subs == []: return ' [No subs]' elif 'Eng' in subs: @@ -193,117 +182,34 @@ def get_subs_tag(self, subs): return ' [No Eng subs]' return f" [{subs[0]} subs only]" - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(await self.get_type(meta)) - resolution_id = await self.get_res_id(meta['resolution']) - await self.edit_desc(meta) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - acm_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - - if meta['bdinfo'] != None: - # bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - mi_dump = None - bd_dump = "" - for each in meta['discs']: - bd_dump = bd_dump + each['summary'].strip() + "\n\n" - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : acm_name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : await self.get_keywords(meta['keywords']), - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - - - async def search_existing(self, meta): + async def search_existing(self, meta, disctype): dupes = [] - console.print("[yellow]Searching for existing torrents on site...") params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdb' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(await self.get_type(meta)), + 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip(), + 'tmdb': meta['tmdb'], + 'categories[]': (await self.get_category_id(meta))['category_id'], + 'types[]': (await self.get_type_id(meta))['type_id'], # A majority of the ACM library doesn't contain resolution information # 'resolutions[]' : await self.get_res_id(meta['resolution']), # 'name' : "" } # Adding Name to search seems to override tmdb try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url=self.search_url, params=params) + if response.status_code == 200: + data = response.json() + for each in data['data']: + result = [each][0]['attributes']['name'] + dupes.append(result) + else: + console.print(f"[bold red]Failed to search torrents. HTTP Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out after 5 seconds") + except httpx.RequestError as e: + console.print(f"[bold red]Unable to search for existing torrents: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") await asyncio.sleep(5) return dupes @@ -315,11 +221,11 @@ async def search_existing(self, meta): # return f' / {original_title} {chr(int("202A", 16))}' # return original_title - async def edit_name(self, meta): + async def get_name(self, meta): name = meta.get('name') aka = meta.get('aka') original_title = meta.get('original_title') - year = str(meta.get('year')) + year = str(meta.get('year')) # noqa F841 audio = meta.get('audio') source = meta.get('source') is_disc = meta.get('is_disc') @@ -328,7 +234,7 @@ async def edit_name(self, meta): if aka != '': # ugly fix to remove the extra space in the title aka = aka + ' ' - name = name.replace (aka, f' / {original_title} {chr(int("202A", 16))}') + name = name.replace(aka, f' / {original_title} {chr(int("202A", 16))}') elif aka == '': if meta.get('title') != original_title: # name = f'{name[:name.find(year)]}/ {original_title} {chr(int("202A", 16))}{name[name.find(year):]}' @@ -336,58 +242,112 @@ async def edit_name(self, meta): if 'AAC' in audio: name = name.replace(audio.strip().replace(" ", " "), audio.replace("AAC ", "AAC")) name = name.replace("DD+ ", "DD+") - name = name.replace ("UHD BluRay REMUX", "Remux") - name = name.replace ("BluRay REMUX", "Remux") - name = name.replace ("H.265", "HEVC") + name = name.replace("UHD BluRay REMUX", "Remux") + name = name.replace("BluRay REMUX", "Remux") + name = name.replace("H.265", "HEVC") if is_disc == 'DVD': - name = name.replace (f'{source} DVD5', f'{resolution} DVD {source}') - name = name.replace (f'{source} DVD9', f'{resolution} DVD {source}') + name = name.replace(f'{source} DVD5', f'{resolution} DVD {source}') + name = name.replace(f'{source} DVD9', f'{resolution} DVD {source}') if audio == meta.get('channels'): - name = name.replace (f'{audio}', f'MPEG {audio}') + name = name.replace(f'{audio}', f'MPEG {audio}') name = name + self.get_subs_tag(subs) - return name + return {'name': name} + async def get_description(self, meta): + async with aiofiles.open(f'{meta["base_dir"]}/tmp/{meta["uuid"]}/DESCRIPTION.txt', 'r', encoding='utf-8') as f: + base = await f.read() + output_path = f'{meta["base_dir"]}/tmp/{meta["uuid"]}/[{self.tracker}]DESCRIPTION.txt' - async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as descfile: + async with aiofiles.open(output_path, 'w', encoding='utf-8') as descfile: from src.bbcode import BBCODE - # Add This line for all web-dls - if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '': - descfile.write(f"[center][b][color=#ff00ff][size=18]This release is sourced from {meta['service_longname']} and is not transcoded, just remuxed from the direct {meta['service_longname']} stream[/size][/color][/b][/center]") + + if meta.get('type') == 'WEBDL' and meta.get('service_longname', ''): + await descfile.write( + f'[center][b][color=#ff00ff][size=18]This release is sourced from {meta["service_longname"]} and is not transcoded,' + f'just remuxed from the direct {meta["service_longname"]} stream[/size][/color][/b][/center]\n' + ) + bbcode = BBCODE() - if meta.get('discs', []) != []: - discs = meta['discs'] - if discs[0]['type'] == "DVD": - descfile.write(f"[spoiler=VOB MediaInfo][code]{discs[0]['vob_mi']}[/code][/spoiler]\n") - descfile.write("\n") + + discs = meta.get('discs', []) + if discs: + if discs[0].get('type') == 'DVD': + await descfile.write(f'[spoiler=VOB MediaInfo][code]{discs[0]["vob_mi"]}[/code][/spoiler]\n\n') + if len(discs) >= 2: for each in discs[1:]: - if each['type'] == "BDMV": + if each.get('type') == 'BDMV': # descfile.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]\n") # descfile.write("\n") pass - if each['type'] == "DVD": - descfile.write(f"{each['name']}:\n") - descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code][{each['vob_mi']}[/code][/spoiler] [spoiler={os.path.basename(each['ifo'])}][code][{each['ifo_mi']}[/code][/spoiler]\n") - descfile.write("\n") - desc = base - desc = bbcode.convert_pre_to_code(desc) + if each.get('type') == 'DVD': + await descfile.write(f'{each.get("name")}:\n') + vob_mi = each.get("vob_mi", '') + ifo_mi = each.get("ifo_mi", '') + await descfile.write( + f'[spoiler={os.path.basename(each["vob"])}][code]{vob_mi}[/code][/spoiler] ' + f'[spoiler={os.path.basename(each["ifo"])}][code]{ifo_mi}[/code][/spoiler]\n\n' + ) + + desc = bbcode.convert_pre_to_code(base) desc = bbcode.convert_hide_to_spoiler(desc) desc = bbcode.convert_comparison_to_collapse(desc, 1000) desc = desc.replace('[img]', '[img=300]') - descfile.write(desc) - images = meta['image_list'] - if len(images) > 0: - descfile.write("[center]") - for each in range(len(images[:int(meta['screens'])])): - web_url = images[each]['web_url'] - img_url = images[each]['img_url'] - descfile.write(f"[url={web_url}][img=350]{img_url}[/img][/url]") - descfile.write("[/center]") - if self.signature != None: - descfile.write(self.signature) - descfile.close() - return + + await descfile.write(desc) + + images = meta.get('image_list', []) + if images: + await descfile.write('[center]\n') + for i in range(min(len(images), int(meta.get('screens', 0)))): + image = images[i] + web_url = image.get('web_url', '') + img_url = image.get('img_url', '') + await descfile.write(f'[url={web_url}][img=350]{img_url}[/img][/url]') + await descfile.write('\n[/center]') + + if self.signature: + await descfile.write(self.signature) + + return {'description': desc} + + async def search_torrent_page(self, meta, disctype): + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + Name = meta['name'] + quoted_name = f'"{Name}"' + + params = { + 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip(), + 'name': quoted_name + } + + try: + response = requests.get(url=self.search_url, params=params) + response.raise_for_status() + response_data = response.json() + + if response_data['data'] and isinstance(response_data['data'], list): + details_link = response_data['data'][0]['attributes'].get('details_link') + + if details_link: + with open(torrent_file_path, 'rb') as open_torrent: + torrent_data = open_torrent.read() + + torrent = bencodepy.decode(torrent_data) + torrent[b'comment'] = details_link.encode('utf-8') + updated_torrent_data = bencodepy.encode(torrent) + + with open(torrent_file_path, 'wb') as updated_torrent_file: + updated_torrent_file.write(updated_torrent_data) + + return details_link + else: + return None + else: + return None + + except requests.exceptions.RequestException as e: + print(f"An error occurred during the request: {e}") + return None diff --git a/src/trackers/AITHER.py b/src/trackers/AITHER.py index 89777724a..54786fd5e 100644 --- a/src/trackers/AITHER.py +++ b/src/trackers/AITHER.py @@ -1,199 +1,60 @@ # -*- coding: utf-8 -*- # import discord -import asyncio -import requests -from difflib import SequenceMatcher -import distutils.util -import json -import os -import platform - +from src.languages import process_desc_language, has_english_language from src.trackers.COMMON import COMMON -from src.console import console +from src.trackers.UNIT3D import UNIT3D + -class AITHER(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ +class AITHER(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='AITHER') self.config = config + self.common = COMMON(config) self.tracker = 'AITHER' self.source_flag = 'Aither' - self.search_url = 'https://aither.cc/api/torrents/filter' - self.upload_url = 'https://aither.cc/api/torrents/upload' - self.signature = f"\n[center][url=https://aither.cc/forums/topics/1349]Created by L4G's Upload Assistant[/url][/center]" - self.banned_groups = ['4K4U', 'AROMA', 'EMBER', 'FGT', 'Hi10', 'ION10', 'Judas', 'LAMA', 'MeGusta', 'QxR', 'RARBG', 'SPDVD', 'STUTTERSHIT', 'SWTYBLZ', 'Sicario', 'TAoE', 'TGx', 'TSP', 'TSPxL', 'Tigole', 'Weasley[HONE]', 'Will1869', 'YIFY', 'd3g', 'nikt0', 'x0r'] + self.base_url = 'https://aither.cc' + self.banned_url = f'{self.base_url}/api/blacklists/releasegroups' + self.claims_url = f'{self.base_url}/api/internals/claim' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] pass - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await common.unit3d_edit_desc(meta, self.tracker, self.signature, comparison=True) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} + + async def get_additional_data(self, meta): data = { - 'name' : name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' + 'mod_queue_opt_in': await self.get_flag(meta, 'modq'), } - params = { - 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() + return data - - async def edit_name(self, meta): + async def get_name(self, meta): aither_name = meta['name'] - has_eng_audio = False - if meta['is_disc'] != "BDMV": - with open(f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/MediaInfo.json", 'r', encoding='utf-8') as f: - mi = json.load(f) - - for track in mi['media']['track']: - if track['@type'] == "Audio": - if track.get('Language', 'None').startswith('en'): - has_eng_audio = True - if not has_eng_audio: - audio_lang = mi['media']['track'][2].get('Language_String', "").upper() - if audio_lang != "": - aither_name = aither_name.replace(meta['resolution'], f"{audio_lang} {meta['resolution']}", 1) - else: - for audio in meta['bdinfo']['audio']: - if audio['language'] == 'English': - has_eng_audio = True - if not has_eng_audio: - audio_lang = meta['bdinfo']['audio'][0]['language'].upper() - if audio_lang != "": - aither_name = aither_name.replace(meta['resolution'], f"{audio_lang} {meta['resolution']}", 1) - if meta['category'] == "TV" and meta.get('tv_pack', 0) == 0 and meta.get('episode_title_storage', '').strip() != '' and meta['episode'].strip() != '': - aither_name = aither_name.replace(meta['episode'], f"{meta['episode']} {meta['episode_title_storage']}", 1) - return aither_name - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id - - async def get_type_id(self, type): - type_id = { - 'DISC': '1', - 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', - 'HDTV': '6', - 'ENCODE': '3' - }.get(type, '0') - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = params['name'] + f" {meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + f" {meta['edition']}" - - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes \ No newline at end of file + resolution = meta.get('resolution') + video_codec = meta.get('video_codec') + video_encode = meta.get('video_encode') + name_type = meta.get('type', "") + source = meta.get('source', "") + + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + audio_languages = meta['audio_languages'][0].upper() + if audio_languages and not await has_english_language(audio_languages): + if (name_type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD")): + aither_name = aither_name.replace(str(meta['year']), f"{meta['year']} {audio_languages}", 1) + elif not meta.get('is_disc') == "BDMV": + aither_name = aither_name.replace(meta['resolution'], f"{audio_languages} {meta['resolution']}", 1) + + if name_type == "DVDRIP": + source = "DVDRip" + aither_name = aither_name.replace(f"{meta['source']} ", "", 1) + aither_name = aither_name.replace(f"{meta['video_encode']}", "", 1) + aither_name = aither_name.replace(f"{source}", f"{resolution} {source}", 1) + aither_name = aither_name.replace((meta['audio']), f"{meta['audio']}{video_encode}", 1) + + elif meta['is_disc'] == "DVD" or (name_type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD")): + aither_name = aither_name.replace((meta['source']), f"{resolution} {meta['source']}", 1) + aither_name = aither_name.replace((meta['audio']), f"{video_codec} {meta['audio']}", 1) + + return {'name': aither_name} diff --git a/src/trackers/AL.py b/src/trackers/AL.py new file mode 100644 index 000000000..2d6ea7342 --- /dev/null +++ b/src/trackers/AL.py @@ -0,0 +1,301 @@ +# -*- coding: utf-8 -*- +# import discord +import re +import requests +from src.console import console +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class AL(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='AL') + self.config = config + self.common = COMMON(config) + self.tracker = 'AL' + self.source_flag = 'al' + self.base_url = 'https://animelovers.club' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_additional_checks(self, meta): + should_continue = True + + if not meta["mal"]: + console.print("[bold red]MAL ID is missing, cannot upload to AL.[/bold red]") + meta["skipping"] = f'{self.tracker}' + return False + + return should_continue + + async def get_category_id(self, meta): + category_id = { + 'MOVIE': '1', + 'TV': '2', + }.get(meta['category'], '0') + + if 'HENTAI' in meta.get('mal_rating', "") or 'HENTAI' in str(meta.get('keywords', '')).upper(): + category_id = '7' + + return {'category_id': category_id} + + async def get_type_id(self, meta): + type_id = { + 'BDMV': '1', + 'DISC': '1', + 'REMUX': '2', + 'ENCODE': '3', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6', + 'DVDISO': '7', + 'DVDRIP': '8', + 'RAW': '9', + 'BDRIP': '10', + 'COLOR': '11', + 'MONO': '12' + }.get(meta['type'], '1') + return {'type_id': type_id} + + async def get_resolution_id(self, meta): + resolution = meta['resolution'] + bit_depth = meta.get('bit_depth', '') + resolution_to_compare = resolution + if bit_depth == "10": + resolution_to_compare = f"{resolution} 10bit" + resolution_id = { + '4320p 10bit': '1', + '4320p': '14', + '2160p 10bit': '2', + '2160p': '13', + '1080p 10bit': '3', + '1080p': '12', + '1080i': '4', + '816p 10bit': '11', + '816p': '16', + '720p 10bit': '5', + '720p': '15', + '576p': '6', + '576i': '7', + '480p': '8', + '480i': '9' + }.get(resolution_to_compare, '10') + return {'resolution_id': resolution_id} + + async def get_name(self, meta): + mal_title = await self.get_mal_data(meta) + category = meta['category'] + title = '' + try: + title = meta['imdb_info']['title'] + except Exception as e: + console.log(e) + title = meta['title'] + year = '' + service = meta.get('service', '') + try: + year = meta['imdb_info']['year'] + except Exception as e: + console.log(e) + year = meta['year'] + season = meta['season'] + episode = meta.get('episode', '') + resolution = meta['resolution'].replace('i', 'p') + bit_depth = meta.get('bit_depth', '') + service = meta['service'] + source = meta['source'] + region = meta.get('region', '') + if meta['is_disc'] is None: + video_type = meta['type'] + audios = await self.format_audios(meta['mediainfo']['media']['track']) + subtitles = await self.format_subtitles(meta['mediainfo']['media']['track']) + else: + video_type = meta['is_disc'] + audios = await self.format_audios_disc(meta['bdinfo']['audio']) + subtitles = await self.format_subtitles_disc(meta['bdinfo']['subtitles']) + tag = meta['tag'] + video_encode = meta.get('video_encode', '') + video_codec = meta['video_codec'] + + name = f"{title}" + if mal_title and title.upper() != mal_title.upper(): + name += f" ({mal_title})" + if category == 'MOVIE': + name += f" {year}" + else: + name += f" {season}{episode}" + + name += f" {resolution}" + + if bit_depth == "10": + name += f" {bit_depth}Bit" + + if service != '': + name += f" {service}" + + if region not in ['', None]: + name += f" {region}" + + if meta['is_disc'] is None: + if source in ['BluRay', 'Blu-ray', 'LaserDisc', 'DCP']: + if source == 'Blu-ray': + source = 'BluRay' + name += f" {source}" + + if video_type != 'ENCODE': + name += f" {video_type}" + else: + name += f" {video_type}" + + name += f" {audios}" + + if len(subtitles.strip()) > 0: + name += f" {subtitles}Subs" + + if len(video_encode.strip()) > 0: + name += f" {video_encode.strip()}" + + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + tag = re.sub(f"-{invalid_tag}", "", tag, flags=re.IGNORECASE) + tag = '-NoGroup' + + if 'AVC' in video_codec and '264' in video_encode: + name += f"{tag.strip()}" + else: + name += f" {video_codec}{tag.strip()}" + + console.print(f"[yellow]Corrected title : [green]{name}") + return {'name': name} + + async def get_mal_data(self, meta): + anime_id = meta['mal'] + response = requests.get(f"https://api.jikan.moe/v4/anime/{anime_id}") + content = response.json() + title = content['data']['title'] if content['data']['title'] else None + meta['mal_rating'] = content['data']['rating'].upper() if content['data']['rating'] else None + return title + + async def format_audios(self, tracks): + formats = {} + audio_tracks = [track for track in tracks if track['@type'] == "Audio"] + for audio_track in audio_tracks: + channels_str = await self.get_correct_channels_str(audio_track['Channels']) + audio_codec = await self.get_correct_audio_codec_str(audio_track['Format']) + audio_format = f"{audio_codec} {channels_str}" + audio_language = await self.get_correct_language_str(audio_track['Language']) + if (formats.get(audio_format, False)): + if audio_language not in formats[audio_format]: + formats[audio_format] += f"-{audio_language}" + else: + formats[audio_format] = audio_language + + audios = "" + for audio_format in formats.keys(): + audios_languages = formats[audio_format] + audios += f"{audios_languages} {audio_format} " + return audios.strip() + + async def format_audios_disc(self, tracks): + formats = {} + for audio_track in tracks: + channels_str = await self.get_correct_channels_str(audio_track['channels']) + audio_codec = await self.get_correct_audio_codec_str(audio_track['codec']) + audio_format = f"{audio_codec} {channels_str}" + audio_language = await self.get_correct_language_str(audio_track['language']) + if (formats.get(audio_format, False)): + if audio_language not in formats[audio_format]: + formats[audio_format] += f"-{audio_language}" + else: + formats[audio_format] = audio_language + + audios = "" + for audio_format in formats.keys(): + audios_languages = formats[audio_format] + audios += f"{audios_languages} {audio_format} " + return audios.strip() + + async def format_subtitles(self, tracks): + subtitles = [] + subtitle_tracks = [track for track in tracks if track['@type'] == "Text"] + for subtitle_track in subtitle_tracks: + subtitle_language = await self.get_correct_language_str(subtitle_track['Language']) + if subtitle_language not in subtitles: + subtitles.append(subtitle_language) + if len(subtitles) > 3: + return 'Multi-' + return "-".join(subtitles) + + async def format_subtitles_disc(self, tracks): + subtitles = [] + for subtitle_track in tracks: + subtitle_language = await self.get_correct_language_str(subtitle_track) + if subtitle_language not in subtitles: + subtitles.append(subtitle_language) + if len(subtitles) > 3: + return 'Multi-' + return "-".join(subtitles) + + async def get_correct_language_str(self, language): + try: + language_upper = language.upper() + if language_upper.startswith('JA'): + return 'Jap' + elif language_upper.startswith('EN'): + return 'Eng' + elif language_upper.startswith('ES'): + return 'Spa' + elif language_upper.startswith('PT'): + return 'Por' + elif language_upper.startswith('FR'): + return 'Fre' + elif language_upper.startswith('AR'): + return 'Ara' + elif language_upper.startswith('IT'): + return 'Ita' + elif language_upper.startswith('RU'): + return 'Rus' + elif language_upper.startswith('ZH') or language_upper.startswith('CHI'): + return 'Chi' + elif language_upper.startswith('DE') or language_upper.startswith('GER'): + return 'Ger' + else: + if len(language) >= 3: + return language[0:3] + else: + return language + except Exception as e: + console.log(e) + return 'UNKOWN' + + async def get_correct_channels_str(self, channels_str): + if channels_str == '6': + return '5.1' + elif channels_str == '5': + return '5.0' + elif channels_str == '2': + return '2.0' + elif channels_str == '1': + return '1.0' + else: + return channels_str + + async def get_correct_audio_codec_str(self, audio_codec_str): + if audio_codec_str == 'AC-3': + return 'AC3' + if audio_codec_str == 'E-AC-3': + return 'DD+' + if audio_codec_str == 'MLP FBA': + return 'TrueHD' + if audio_codec_str == 'DTS-HD Master Audio': + return 'DTS' + if audio_codec_str == 'Dolby Digital Audio': + return 'DD' + else: + return audio_codec_str diff --git a/src/trackers/ANT.py b/src/trackers/ANT.py index 0bd5c40b8..7bedb331f 100644 --- a/src/trackers/ANT.py +++ b/src/trackers/ANT.py @@ -1,40 +1,34 @@ # -*- coding: utf-8 -*- # import discord -import os +import aiofiles import asyncio -import requests -import distutils.util +import httpx +import json +import os import platform -from pymediainfo import MediaInfo - -from src.trackers.COMMON import COMMON +from pathlib import Path from src.console import console +from src.torrentcreate import create_torrent +from src.trackers.COMMON import COMMON -class ANT(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - - ############################################################### - # ####### EDIT ME ##### # - ############################################################### - - # ALSO EDIT CLASS NAME ABOVE - +class ANT: def __init__(self, config): self.config = config + self.common = COMMON(config) self.tracker = 'ANT' self.source_flag = 'ANT' self.search_url = 'https://anthelion.me/api.php' self.upload_url = 'https://anthelion.me/api.php' - self.banned_groups = ['Ozlem', 'RARBG', 'FGT', 'STUTTERSHIT', 'LiGaS', 'DDR', 'Zeus', 'TBS', 'aXXo', 'CrEwSaDe', 'DNL', 'EVO', - 'FaNGDiNG0', 'HD2DVD', 'HDTime', 'iPlanet', 'KiNGDOM', 'NhaNc3', 'PRoDJi', 'SANTi', 'ViSiON', 'WAF', 'YIFY', - 'YTS', 'MkvCage', 'mSD'] + self.banned_groups = [ + '3LTON', '4yEo', 'ADE', 'AFG', 'AniHLS', 'AnimeRG', 'AniURL', 'AROMA', 'aXXo', 'Brrip', 'CHD', 'CM8', + 'CrEwSaDe', 'd3g', 'DDR', 'DNL', 'DeadFish', 'ELiTE', 'eSc', 'FaNGDiNG0', 'FGT', 'Flights', 'FRDS', + 'FUM', 'HAiKU', 'HD2DVD', 'HDS', 'HDTime', 'Hi10', 'ION10', 'iPlanet', 'JIVE', 'KiNGDOM', 'Leffe', + 'LiGaS', 'LOAD', 'MeGusta', 'MkvCage', 'mHD', 'mSD', 'NhaNc3', 'nHD', 'NOIVTC', 'nSD', 'Oj', 'Ozlem', + 'PiRaTeS', 'PRoDJi', 'RAPiDCOWS', 'RARBG', 'RetroPeeps', 'RDN', 'REsuRRecTioN', 'RMTeam', 'SANTi', + 'SicFoI', 'SPASM', 'SPDVD', 'STUTTERSHIT', 'TBS', 'Telly', 'TM', 'UPiNSMOKE', 'URANiME', 'WAF', 'xRed', + 'XS', 'YIFY', 'YTS', 'Zeus', 'ZKBL', 'ZmN', 'ZMNT' + ] self.signature = None pass @@ -46,7 +40,7 @@ async def get_flags(self, meta): for each in ['Dual-Audio', 'Atmos']: if each in meta['audio']: flags.append(each.replace('-', '')) - if meta.get('has_commentary', False): + if meta.get('has_commentary', False) or meta.get('manual_commentary', False): flags.append('Commentary') if meta['3D'] == "3D": flags.append('3D') @@ -60,92 +54,240 @@ async def get_flags(self, meta): flags.append('Remux') return flags - ############################################################### - # #### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ### # - ############################################################### + async def upload(self, meta, disctype): + torrent_filename = "BASE" + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent" + torrent_file_size_kib = os.path.getsize(torrent_path) / 1024 + + # Trigger regeneration automatically if size constraints aren't met + if torrent_file_size_kib > 250: # 250 KiB + console.print("[yellow]Existing .torrent exceeds 250 KiB and will be regenerated to fit constraints.") + meta['max_piece_size'] = '256' # 256 MiB + create_torrent(meta, Path(meta['path']), "ANT") + torrent_filename = "ANT" - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) + await self.common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename=torrent_filename) flags = await self.get_flags(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) is False: - anon = 0 - else: - anon = 1 - if meta['bdinfo'] is not None: - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - bd_dump = f'[spoiler=BDInfo][pre]{bd_dump}[/pre][/spoiler]' - path = os.path.join(meta['bdinfo']['path'], 'STREAM') - m2ts = os.path.join(path, meta['bdinfo']['files'][0]['file']) - media_info_output = str(MediaInfo.parse(m2ts, output="text", full=False)) - mi_dump = media_info_output.replace('\r\n', '\n') - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'file_input': open_torrent} + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + async with aiofiles.open(torrent_file_path, 'rb') as f: + torrent_bytes = await f.read() + files = {'file_input': ('torrent.torrent', torrent_bytes, 'application/x-bittorrent')} data = { + 'type': 0, + 'audioformat': await self.get_audio(meta), 'api_key': self.config['TRACKERS'][self.tracker]['api_key'].strip(), 'action': 'upload', 'tmdbid': meta['tmdb'], - 'mediainfo': mi_dump, + 'mediainfo': await self.mediainfo(meta), 'flags[]': flags, - 'anonymous': anon, - 'screenshots': '\n'.join([x['raw_url'] for x in meta['image_list']][:4]) + 'screenshots': '\n'.join([x['raw_url'] for x in meta['image_list']][:4]), + 'release_desc': await self.edit_desc(meta), } if meta['bdinfo'] is not None: data.update({ 'media': 'Blu-ray', 'releasegroup': str(meta['tag'])[1:], - 'release_desc': bd_dump, - 'flagchangereason': "BDMV Uploaded with L4G's Upload Assistant"}) + 'flagchangereason': "BDMV Uploaded with Upload Assistant" + }) if meta['scene']: # ID of "Scene?" checkbox on upload form is actually "censored" data['censored'] = 1 headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' + 'User-Agent': f'Upload Assistant/2.3 ({platform.system()} {platform.release()})' + } + + try: + if not meta['debug']: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post(url=self.upload_url, files=files, data=data, headers=headers) + if response.status_code in [200, 201]: + try: + response_data = response.json() + except json.JSONDecodeError: + meta['tracker_status'][self.tracker]['status_message'] = "data error: ANT json decode error, the API is probably down" + return + if "Success" not in response_data: + meta['tracker_status'][self.tracker]['status_message'] = f"data error - {response_data}" + if meta.get('tag', '') and 'HONE' in meta.get('tag', ''): + meta['tracker_status'][self.tracker]['status_message'] = f"{response_data} - HONE release, fix tag at ANT" + else: + meta['tracker_status'][self.tracker]['status_message'] = response_data + elif response.status_code == 502: + response_data = { + "error": "Bad Gateway", + "site seems down": "https://ant.trackerstatus.info/" + } + meta['tracker_status'][self.tracker]['status_message'] = f"data error - {response_data}" + else: + response_data = { + "error": f"Unexpected status code: {response.status_code}", + "response_content": response.text + } + meta['tracker_status'][self.tracker]['status_message'] = f"data error - {response_data}" + else: + console.print("[cyan]ANT Request Data:") + console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." + except Exception as e: + meta['tracker_status'][self.tracker]['status_message'] = f"data error: ANT upload failed: {e}" + + async def get_audio(self, meta): + ''' + Possible values: + MP2, MP3, AAC, AC3, DTS, FLAC, PCM, True-HD, Opus + ''' + audio = meta.get('audio', '').upper() + audio_map = { + 'MP2': 'MP2', + 'MP3': 'MP3', + 'AAC': 'AAC', + 'DD': 'AC3', + 'DTS': 'DTS', + 'FLAC': 'FLAC', + 'PCM': 'PCM', + 'TRUEHD': 'True-HD', + 'OPUS': 'Opus' } - if meta['debug'] is False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers) - if response.status_code in [200, 201]: - response = response.json() - try: - console.print(response) - except Exception: - console.print("It may have uploaded, go check") - return + for key, value in audio_map.items(): + if key in audio: + return value + console.print(f'{self.tracker}: Unexpected audio format: {audio}. The format must be one of the following: MP2, MP3, AAC, AC3, DTS, FLAC, PCM, True-HD, Opus') + return None + + async def mediainfo(self, meta): + if meta.get('is_disc') == 'BDMV': + mediainfo = await self.common.get_bdmv_mediainfo(meta, remove=['File size', 'Overall bit rate']) else: - console.print("[cyan]Request Data:") - console.print(data) - open_torrent.close() + mi_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + async with aiofiles.open(mi_path, 'r', encoding='utf-8') as f: + mediainfo = await f.read() + + return mediainfo async def edit_desc(self, meta): + if meta.get('is_disc') == 'BDMV': + bd_info = meta.get('discs', [{}])[0].get('summary', '') + if bd_info: + return f'[spoiler=BDInfo][pre]{bd_info}[/pre][/spoiler]' return - async def search_existing(self, meta): + async def search_existing(self, meta, disctype): + if meta.get('category') == "TV": + if not meta['unattended']: + console.print('[bold red]ANT only ALLOWS Movies.') + meta['skipping'] = "ANT" + return [] dupes = [] - console.print("[yellow]Searching for existing torrents on site...") params = { 'apikey': self.config['TRACKERS'][self.tracker]['api_key'].strip(), 't': 'search', 'o': 'json' } - if str(meta['tmdb']) != "0": + if str(meta['tmdb']) != 0: params['tmdb'] = meta['tmdb'] - elif int(meta['imdb_id'].replace('tt', '')) != 0: - params['imdb'] = meta['imdb_id'] + elif int(meta['imdb_id']) != 0: + params['imdb'] = meta['imdb'] + try: - response = requests.get(url='https://anthelion.me/api', params=params) - response = response.json() - for each in response['item']: - largest = [each][0]['files'][0] - for file in [each][0]['files']: - if int(file['size']) > int(largest['size']): - largest = file - result = largest['name'] - dupes.append(result) - except Exception: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url='https://anthelion.me/api', params=params) + if response.status_code == 200: + try: + data = response.json() + target_resolution = meta.get('resolution', '').lower() + + for each in data.get('item', []): + if target_resolution and each.get('resolution', '').lower() != target_resolution.lower(): + if meta.get('debug'): + console.print(f"[yellow]Skipping {each.get('fileName')} - resolution mismatch: {each.get('resolution')} vs {target_resolution}") + continue + + largest_file = None + if 'files' in each and len(each['files']) > 0: + largest = each['files'][0] + for file in each['files']: + current_size = int(file.get('size', 0)) + largest_size = int(largest.get('size', 0)) + if current_size > largest_size: + largest = file + largest_file = largest.get('name', '') + + result = { + 'name': largest_file or each.get('fileName', ''), + 'files': [file.get('name', '') for file in each.get('files', [])], + 'size': int(each.get('size', 0)), + 'link': each.get('guid', ''), + 'flags': each.get('flags', []), + 'file_count': each.get('fileCount', 0) + } + dupes.append(result) + + if meta.get('debug'): + console.print(f"[green]Found potential dupe: {result['name']} ({result['size']} bytes)") + + except json.JSONDecodeError: + console.print("[bold yellow]ANT response content is not valid JSON. Skipping this API call.") + meta['skipping'] = "ANT" + else: + console.print(f"[bold red]ANT failed to search torrents. HTTP Status: {response.status_code}") + meta['skipping'] = "ANT" + except httpx.TimeoutException: + console.print("[bold red]ANT Request timed out after 5 seconds") + meta['skipping'] = "ANT" + except httpx.RequestError as e: + console.print(f"[bold red]ANT unable to search for existing torrents: {e}") + meta['skipping'] = "ANT" + except Exception as e: + console.print(f"[bold red]ANT unexpected error: {e}") + meta['skipping'] = "ANT" await asyncio.sleep(5) return dupes + + async def get_data_from_files(self, meta): + if meta.get('is_disc', False): + return [] + filelist = meta.get('filelist', []) + filename = [os.path.basename(f) for f in filelist][0] + params = { + 'apikey': self.config['TRACKERS'][self.tracker]['api_key'].strip(), + 't': 'search', + 'filename': filename, + 'o': 'json' + } + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url='https://anthelion.me/api', params=params) + if response.status_code == 200: + try: + data = response.json() + imdb_tmdb_list = [] + items = data.get('item', []) + if len(items) == 1: + each = items[0] + imdb_id = each.get('imdb') + tmdb_id = each.get('tmdb') + if imdb_id and imdb_id.startswith('tt'): + imdb_num = int(imdb_id[2:]) + imdb_tmdb_list.append({'imdb_id': imdb_num}) + if tmdb_id and str(tmdb_id).isdigit() and int(tmdb_id) != 0: + imdb_tmdb_list.append({'tmdb_id': int(tmdb_id)}) + except json.JSONDecodeError: + console.print("[bold yellow]Error parsing JSON response from ANT") + imdb_tmdb_list = [] + else: + console.print(f"[bold red]Failed to search torrents. HTTP Status: {response.status_code}") + imdb_tmdb_list = [] + except httpx.TimeoutException: + console.print("[bold red]ANT Request timed out after 5 seconds") + imdb_tmdb_list = [] + except httpx.RequestError as e: + console.print(f"[bold red]Unable to search for existing torrents: {e}") + imdb_tmdb_list = [] + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + imdb_tmdb_list = [] + + return imdb_tmdb_list diff --git a/src/trackers/AR.py b/src/trackers/AR.py new file mode 100644 index 000000000..a57a35113 --- /dev/null +++ b/src/trackers/AR.py @@ -0,0 +1,615 @@ +# -*- coding: utf-8 -*- +import aiofiles +import aiohttp +import json +import os +import pickle +import platform +import re +import asyncio +import signal +from rich.prompt import Prompt +import urllib.parse +from src.exceptions import * # noqa F403 +from bs4 import BeautifulSoup +from src.console import console +from src.trackers.COMMON import COMMON +from pymediainfo import MediaInfo + + +class AR(): + def __init__(self, config): + self.config = config + self.session = None + self.tracker = 'AR' + self.source_flag = 'AlphaRatio' + self.username = config['TRACKERS']['AR'].get('username', '').strip() + self.password = config['TRACKERS']['AR'].get('password', '').strip() + self.base_url = 'https://alpharatio.cc' + self.login_url = f'{self.base_url}/login.php' + self.upload_url = f'{self.base_url}/upload.php' + self.search_url = f'{self.base_url}/torrents.php' + self.user_agent = f'Upload Assistant/2.3 ({platform.system()} {platform.release()})' + self.signature = None + self.banned_groups = [] + + async def get_type(self, meta): + + if (meta['type'] == 'DISC' or meta['type'] == 'REMUX') and meta['source'] == 'Blu-ray': + return "14" + + if meta.get('anime'): + if meta['sd']: + return '15' + else: + return { + '8640p': '16', + '4320p': '16', + '2160p': '16', + '1440p': '16', + '1080p': '16', + '1080i': '16', + '720p': '16', + }.get(meta['resolution'], '15') + + elif meta['category'] == "TV": + if meta['tv_pack']: + if meta['sd']: + return '4' + else: + return { + '8640p': '6', + '4320p': '6', + '2160p': '6', + '1440p': '5', + '1080p': '5', + '1080i': '5', + '720p': '5', + }.get(meta['resolution'], '4') + elif meta['sd']: + return '0' + else: + return { + '8640p': '2', + '4320p': '2', + '2160p': '2', + '1440p': '1', + '1080p': '1', + '1080i': '1', + '720p': '1', + }.get(meta['resolution'], '0') + + if meta['category'] == "MOVIE": + if meta['sd']: + return '7' + else: + return { + '8640p': '9', + '4320p': '9', + '2160p': '9', + '1440p': '8', + '1080p': '8', + '1080i': '8', + '720p': '8', + }.get(meta['resolution'], '7') + + async def start_session(self): + if self.session is not None: + console.print("[dim red]Warning: Previous session was not closed properly. Closing it now.") + await self.close_session() + self.session = aiohttp.ClientSession() + + self.attach_signal_handlers() + return aiohttp + + async def close_session(self): + if self.session is not None: + await self.session.close() + self.session = None + + def attach_signal_handlers(self): + loop = asyncio.get_running_loop() + + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, lambda: asyncio.create_task(self.handle_shutdown(sig))) + except NotImplementedError: + pass + + async def handle_shutdown(self, sig): + console.print(f"[red]Received shutdown signal {sig}. Closing session...[/red]") + await self.close_session() + + async def validate_credentials(self, meta): + if self.session: + console.print("[red dim]Warning: Previous session was not closed properly. Using existing session.") + else: + try: + await self.start_session() + except asyncio.CancelledError: + console.print("[red]Session startup interrupted! Cleaning up...[/red]") + await self.close_session() + raise + + if await self.load_session(meta): + response = await self.get_initial_response() + if await self.validate_login(response): + return True + else: + console.print("[yellow]No session file found. Attempting to log in...") + if await self.login(): + console.print("[green]Login successful, saving session file.") + valid = await self.save_session(meta) + if valid: + if meta['debug']: + console.print("[blue]Session file saved successfully.") + return True + else: + return False + else: + console.print('[red]Failed to validate credentials. Please confirm that the site is up and your passkey is valid. Exiting') + + await self.close_session() + return False + + async def get_initial_response(self): + async with self.session.get(self.login_url) as response: + return await response.text() + + async def validate_login(self, response_text): + if 'login.php?act=recover' in response_text: + console.print("[red]Login failed. Check your credentials.") + return False + return True + + async def login(self): + data = { + "username": self.username, + "password": self.password, + "keeplogged": "1", + "login": "Login", + } + async with self.session.post(self.login_url, data=data) as response: + if await self.validate_login(await response.text()): + return True + return False + + async def save_session(self, meta): + try: + session_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.pkl") + os.makedirs(os.path.dirname(session_file), exist_ok=True) + cookies = self.session.cookie_jar + cookie_dict = {} + for cookie in cookies: + cookie_dict[cookie.key] = cookie.value + + loop = asyncio.get_running_loop() + data = await loop.run_in_executor(None, pickle.dumps, cookie_dict) + async with aiofiles.open(session_file, 'wb') as f: + await f.write(data) + except Exception as e: + console.print(f"[red]Error saving session: {e}[/red]") + return False + + async def load_session(self, meta): + session_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.pkl") + retry_count = 0 + max_retries = 2 + + while retry_count < max_retries: + try: + if not os.path.exists(session_file): + console.print(f"[red]Session file not found: {session_file}[/red]") + return False # No session file to load + + loop = asyncio.get_running_loop() + async with aiofiles.open(session_file, 'rb') as f: + data = await f.read() + try: + cookie_dict = await loop.run_in_executor(None, pickle.loads, data) + except (EOFError, pickle.UnpicklingError) as e: + console.print(f"[red]Error loading session cookies: {e}[/red]") + return False # Corrupted session file + + if self.session is None: + await self.start_session() + + for name, value in cookie_dict.items(): + self.session.cookie_jar.update_cookies({name: value}) + + try: + async with self.session.get(f'{self.base_url}/torrents.php', timeout=10) as response: + if response.status == 200: + console.print("[green]Session validated successfully.[/green]") + return True # Session is valid + else: + console.print(f"[yellow]Session validation failed with status {response.status}, retrying...[/yellow]") + + except (aiohttp.ClientError, asyncio.TimeoutError) as e: + console.print(f"[yellow]Session might be invalid: {e}. Retrying...[/yellow]") + + except (FileNotFoundError, EOFError, pickle.UnpicklingError) as e: + console.print(f"[red]Session loading error: {e}. Closing session and retrying.[/red]") + + await self.close_session() + await self.start_session() + retry_count += 1 + + console.print("[red]Failed to reuse session after retries. Either try again or delete the cookie.[/red]") + return False + + def get_links(self, movie, subheading, heading_end): + description = "" + description += "\n" + subheading + "Links" + heading_end + "\n" + if 'IMAGES' in self.config: + if movie['imdb_id'] != 0: + description += f"[URL=https://www.imdb.com/title/tt{movie['imdb']}][img]{self.config['IMAGES']['imdb_75']}[/img][/URL]" + if movie['tmdb'] != 0: + description += f" [URL=https://www.themoviedb.org/{str(movie['category'].lower())}/{str(movie['tmdb'])}][img]{self.config['IMAGES']['tmdb_75']}[/img][/URL]" + if movie['tvdb_id'] != 0: + description += f" [URL=https://www.thetvdb.com/?id={str(movie['tvdb_id'])}&tab=series][img]{self.config['IMAGES']['tvdb_75']}[/img][/URL]" + if movie['tvmaze_id'] != 0: + description += f" [URL=https://www.tvmaze.com/shows/{str(movie['tvmaze_id'])}][img]{self.config['IMAGES']['tvmaze_75']}[/img][/URL]" + if movie['mal_id'] != 0: + description += f" [URL=https://myanimelist.net/anime/{str(movie['mal_id'])}][img]{self.config['IMAGES']['mal_75']}[/img][/URL]" + else: + if movie['imdb_id'] != 0: + description += f"https://www.imdb.com/title/tt{movie['imdb']}" + if movie['tmdb'] != 0: + description += f"\nhttps://www.themoviedb.org/{str(movie['category'].lower())}/{str(movie['tmdb'])}" + if movie['tvdb_id'] != 0: + description += f"\nhttps://www.thetvdb.com/?id={str(movie['tvdb_id'])}&tab=series" + if movie['tvmaze_id'] != 0: + description += f"\nhttps://www.tvmaze.com/shows/{str(movie['tvmaze_id'])}" + if movie['mal_id'] != 0: + description += f"\nhttps://myanimelist.net/anime/{str(movie['mal_id'])}" + return description + + async def edit_desc(self, meta): + heading = "[COLOR=GREEN][size=6]" + subheading = "[COLOR=RED][size=4]" + heading_end = "[/size][/COLOR]" + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf8') as f: + base = await f.read() + base = re.sub(r'\[center\]\[spoiler=Scene NFO:\].*?\[/center\]', '', base, flags=re.DOTALL) + base = re.sub(r'\[center\]\[spoiler=FraMeSToR NFO:\].*?\[/center\]', '', base, flags=re.DOTALL) + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf8') as descfile: + description = "" + if meta['is_disc'] == "BDMV": + description += heading + str(meta['name']) + heading_end + "\n" + self.get_links(meta, subheading, heading_end) + "\n\n" + subheading + "BDINFO" + heading_end + "\n" + else: + description += heading + str(meta['name']) + heading_end + "\n" + self.get_links(meta, subheading, heading_end) + "\n\n" + subheading + "MEDIAINFO" + heading_end + "\n" + if meta.get('discs', []) != []: + discs = meta['discs'] + if len(discs) >= 2: + for each in discs[1:]: + if each['type'] == "BDMV": + description += f"[hide={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/hide]\n\n" + if each['type'] == "DVD": + description += f"{each['name']}:\n" + description += f"[hide={os.path.basename(each['vob'])}][code][{each['vob_mi']}[/code][/hide] [hide={os.path.basename(each['ifo'])}][code][{each['ifo_mi']}[/code][/hide]\n\n" + # description += common.get_links(movie, "[COLOR=red][size=4]", "[/size][/color]") + elif discs[0]['type'] == "DVD": + description += f"[hide][code]{discs[0]['vob_mi']}[/code][/hide]\n\n" + elif meta['is_disc'] == "BDMV": + description += f"[hide][code]{discs[0]['summary']}[/code][/hide]\n\n" + else: + # Beautify MediaInfo for AR using custom template + video = meta['filelist'][0] + # using custom mediainfo template. + # can not use full media info as sometimes its more than max chars per post. + mi_template = os.path.abspath(f"{meta['base_dir']}/data/templates/summary-mediainfo.csv") + if os.path.exists(mi_template): + media_info = await self.parse_mediainfo_async(video, mi_template) + description += (f"""[code]\n{media_info}\n[/code]\n""") + # adding full mediainfo as spoiler + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8') as MI: + full_mediainfo = await MI.read() + description += f"[hide=FULL MEDIAINFO][code]{full_mediainfo}[/code][/hide]\n" + else: + console.print("[bold red]Couldn't find the MediaInfo template") + console.print("[green]Using normal MediaInfo for the description.") + + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8') as MI: + cleaned_mediainfo = await MI.read() + description += (f"""[code]\n{cleaned_mediainfo}\n[/code]\n\n""") + + description += "\n\n" + subheading + "PLOT" + heading_end + "\n" + str(meta['overview']) + if meta['genres']: + description += "\n\n" + subheading + "Genres" + heading_end + "\n" + str(meta['genres']) + + if meta['image_list'] is not None and len(meta['image_list']) > 0: + description += "\n\n" + subheading + "Screenshots" + heading_end + "\n" + description += "[align=center]" + for image in meta['image_list']: + if image['raw_url'] is not None: + description += "[url=" + image['raw_url'] + "][img]" + image['img_url'] + "[/img][/url]" + description += "[/align]" + if 'youtube' in meta: + description += "\n\n" + subheading + "Youtube" + heading_end + "\n" + str(meta['youtube']) + + # adding extra description if passed + if len(base) > 2: + description += "\n\n" + subheading + "Notes" + heading_end + "\n" + str(base) + + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf8') as descfile: + await descfile.write(description) + return + + async def get_language_tag(self, meta): + lang_tag = "" + has_eng_audio = False + audio_lang = "" + if meta['is_disc'] != "BDMV": + try: + async with aiofiles.open(f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/MediaInfo.json", 'r', encoding='utf-8') as f: + mi_content = await f.read() + mi = json.loads(mi_content) + for track in mi['media']['track']: + if track['@type'] == "Audio": + if track.get('Language', 'None').startswith('en'): + has_eng_audio = True + if not has_eng_audio: + audio_lang = mi['media']['track'][2].get('Language_String', "").upper() + except Exception as e: + console.print(f"[red]Error: {e}") + else: + for audio in meta['bdinfo']['audio']: + if audio['language'] == 'English': + has_eng_audio = True + if not has_eng_audio: + audio_lang = meta['bdinfo']['audio'][0]['language'].upper() + if audio_lang != "": + lang_tag = audio_lang + return lang_tag + + async def get_basename(self, meta): + path = next(iter(meta['filelist']), meta['path']) + return os.path.basename(path) + + async def search_existing(self, meta, DISCTYPE): + dupes = {} + + # Combine title and year + title = str(meta.get('title', '')).strip() + year = str(meta.get('year', '')).strip() + if not title: + await self.close_session() + console.print("[red]Title is missing.") + return dupes + + search_query = f"{title} {year}".strip() # Concatenate title and year + search_query_encoded = urllib.parse.quote(search_query) + + search_url = f'{self.base_url}/ajax.php?action=browse&searchstr={search_query_encoded}' + + if meta.get('debug', False): + console.print(f"[blue]{search_url}") + + try: + async with self.session.get(search_url) as response: + if response.status != 200: + await self.close_session() + console.print("[bold red]Request failed. Site May be down") + return dupes + + json_response = await response.json() + if json_response.get('status') != 'success': + await self.close_session() + console.print("[red]Invalid response status.") + return dupes + + results = json_response.get('response', {}).get('results', []) + if not results: + await self.close_session() + return dupes + + dupes = [] + for res in results: + if 'groupName' in res: + dupe = { + 'name': res['groupName'], + 'size': res['size'], + 'files': res['groupName'], + 'file_count': res['fileCount'], + 'link': f'{self.search_url}?id={res["groupId"]}&torrentid={res["torrentId"]}', + } + dupes.append(dupe) + + await self.close_session() + return dupes + + except Exception as e: + console.print(f"[red]Error occurred: {e}") + + if meta['debug']: + console.print(f"[blue]{dupes}") + await self.close_session() + return dupes + + def _has_existing_torrents(self, response_text): + """Check the response text for existing torrents.""" + return 'Your search did not match anything.' not in response_text + + def extract_auth_key(self, response_text): + soup = BeautifulSoup(response_text, 'html.parser') + logout_link = soup.find('a', href=True, text='Logout') + + if logout_link: + href = logout_link['href'] + match = re.search(r'auth=([^&]+)', href) + if match: + return match.group(1) + return None + + async def upload(self, meta, disctype): + try: + # Prepare the data for the upload + common = COMMON(config=self.config) + await common.edit_torrent(meta, self.tracker, self.source_flag) + await self.edit_desc(meta) + type = await self.get_type(meta) + # Read the description + desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + try: + async with aiofiles.open(desc_path, 'r', encoding='utf-8') as desc_file: + desc = await desc_file.read() + except FileNotFoundError: + raise Exception(f"Description file not found at {desc_path} ") + + # Handle cover image input + cover = meta.get('poster', None) or meta["imdb_info"].get("cover", None) + while cover is None and not meta.get("unattended", False): + cover = Prompt.ask("No Poster was found. Please input a link to a poster:", default="") + if not re.match(r'https?://.*\.(jpg|png|gif)$', cover): + console.print("[red]Invalid image link. Please enter a link that ends with .jpg, .png, or .gif.") + cover = None + # Tag Compilation + genres = meta.get('genres') + if genres: + genres = ', '.join(tag.strip('.') for tag in (item.replace(' ', '.') for item in genres.split(','))) + genres = re.sub(r'\.{2,}', '.', genres) + # adding tags + tags = "" + if meta['imdb_id'] != 0: + tags += f"tt{meta.get('imdb', '')}, " + # no special chars can be used in tags. keep to minimum working tags only. + tags += f"{genres}, " + # Get initial response and extract auth key + initial_response = await self.get_initial_response() + auth_key = self.extract_auth_key(initial_response) + # Access the session cookie + cookies = self.session.cookie_jar.filter_cookies(self.upload_url) + session_cookie = cookies.get('session') + if not session_cookie: + raise Exception("Session cookie not found.") + + # must use scene name if scene release + KNOWN_EXTENSIONS = {".mkv", ".mp4", ".avi", ".ts"} + if meta['scene']: + ar_name = meta.get('scene_name') + else: + ar_name = meta['uuid'] + base, ext = os.path.splitext(ar_name) + if ext.lower() in KNOWN_EXTENSIONS: + ar_name = base + ar_name = ar_name.replace(' ', ".").replace("'", '').replace(':', '').replace("(", '.').replace(")", '.').replace("[", '.').replace("]", '.').replace("{", '.').replace("}", '.') + ar_name = re.sub(r'\.{2,}', '.', ar_name) + + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + ar_name = re.sub(f"-{invalid_tag}", "", ar_name, flags=re.IGNORECASE) + ar_name = f"{ar_name}-NoGRP" + + data = { + "submit": "true", + "auth": auth_key, + "type": type, + "title": ar_name, + "tags": tags, + "image": cover, + "desc": desc, + } + + headers = { + "User-Agent": self.user_agent, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", + "Origin": f'{self.base_url}', + "Referer": f'{self.base_url}/upload.php', + "Cookie": f"session={session_cookie.value}", + } + + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + if meta['debug'] is False: + # Use existing session instead of creating a new one if possible + upload_session = self.session or None + try: + async with aiofiles.open(torrent_path, 'rb') as torrent_file: + torrent_data = await torrent_file.read() + + # Create a new session only if we don't have one + if upload_session is None: + async with aiohttp.ClientSession() as session: + form = aiohttp.FormData() + for key, value in data.items(): + form.add_field(key, value) + form.add_field('file_input', torrent_data, filename=f"{self.tracker}.torrent") + + # Perform the upload + try: + async with session.post(self.upload_url, data=form, headers=headers) as response: + await asyncio.sleep(1) # Give some time for the upload to process + await self._handle_upload_response(response, meta, data) + except Exception: + await self.close_session() + meta['tracker_status'][self.tracker]['status_message'] = "data error - AR it may have uploaded, go check" + return + else: + # Use existing session + form = aiohttp.FormData() + for key, value in data.items(): + form.add_field(key, value) + form.add_field('file_input', torrent_data, filename=f"{self.tracker}.torrent") + # Perform the upload + try: + async with upload_session.post(self.upload_url, data=form, headers=headers) as response: + await asyncio.sleep(1) + await self._handle_upload_response(response, meta, data) + except Exception: + await self.close_session() + meta['tracker_status'][self.tracker]['status_message'] = "data error - AR it may have uploaded, go check" + return + except FileNotFoundError: + meta['tracker_status'][self.tracker]['status_message'] = f"data error - AR file not found: {torrent_path}" + await self.close_session() + return + else: + await self.close_session() + console.print("[cyan]AR Request Data:") + console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." + except Exception as e: + await self.close_session() + meta['tracker_status'][self.tracker]['status_message'] = f"data error - AR Upload failed: {e}" + return + + async def _handle_upload_response(self, response, meta, data): + if response.status == 200: + # URL format in case of successful upload: https://alpharatio.cc/torrents.php?id=2989202 + console.print(f"[green]{response.url}") + match = re.match(r".*?alpharatio\.cc/torrents\.php\?id=(\d+)", str(response.url)) + try: + if match is None: + await self.close_session() + console.print(response.url) + console.print(data) + raise UploadException( # noqa F405 + f"Upload to {self.tracker} failed: result URL {response.url} ({response.status}) is not the expected one.") # noqa F405 + + # having UA add the torrent link as a comment. + if match: + await self.close_session() + common = COMMON(config=self.config) + await common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.config['TRACKERS'][self.tracker].get('announce_url'), str(response.url)) + except Exception as e: + console.print(f"[red]Error: {e}") + await self.close_session() + return + else: + console.print("[red]Upload failed. Response was not 200.") + + async def parse_mediainfo_async(self, video_path, template_path): + """Parse MediaInfo asynchronously using thread executor""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor( + None, + lambda: MediaInfo.parse( + video_path, + output="STRING", + full=False, + mediainfo_options={"inform": f"file://{template_path}"} + ) + ) diff --git a/src/trackers/ASC.py b/src/trackers/ASC.py new file mode 100644 index 000000000..a89ddd664 --- /dev/null +++ b/src/trackers/ASC.py @@ -0,0 +1,919 @@ +# -*- coding: utf-8 -*- +import asyncio +import json +import httpx +import os +import platform +import re +from .COMMON import COMMON +from bs4 import BeautifulSoup +from datetime import datetime +from pymediainfo import MediaInfo +from src.console import console +from src.languages import process_desc_language +from src.tmdb import get_tmdb_localized_data + + +class ASC(COMMON): + def __init__(self, config): + super().__init__(config) + self.tracker = 'ASC' + self.source_flag = 'ASC' + self.banned_groups = [''] + self.base_url = 'https://cliente.amigos-share.club' + self.torrent_url = 'https://cliente.amigos-share.club/torrents-details.php?id=' + self.announce = self.config['TRACKERS'][self.tracker]['announce_url'] + self.layout = self.config['TRACKERS'][self.tracker].get('custom_layout', '2') + self.session = httpx.AsyncClient(headers={ + 'User-Agent': f"Upload Assistant/2.3 ({platform.system()} {platform.release()})" + }, timeout=60.0) + self.signature = "[center][url=https://github.com/Audionut/Upload-Assistant]Upload realizado via Upload Assistant[/url][/center]" + + self.language_map = { + 'bg': '15', 'da': '12', + 'de': '3', 'en': '1', + 'es': '6', 'fi': '14', + 'fr': '2', 'hi': '23', + 'it': '4', 'ja': '5', + 'ko': '20', 'nl': '17', + 'no': '16', 'pl': '19', + 'pt': '8', 'ru': '7', + 'sv': '13', 'th': '21', + 'tr': '25', 'zh': '10', + } + self.anime_language_map = { + 'de': '3', 'en': '4', + 'es': '1', 'ja': '8', + 'ko': '11', 'pt': '5', + 'ru': '2', 'zh': '9', + } + + async def load_cookies(self, meta): + cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + if not os.path.exists(cookie_file): + console.print(f'[bold red]Arquivo de cookie para o {self.tracker} não encontrado: {cookie_file}[/bold red]') + return False + + self.session.cookies = await self.parseCookieFile(cookie_file) + + async def validate_credentials(self, meta): + await self.load_cookies(meta) + try: + test_url = f'{self.base_url}/gerador.php' + response = await self.session.get(test_url, timeout=30.0) + + response.raise_for_status() + + if 'gerador.php' in str(response.url): + return True + else: + console.print(f'[bold red]Falha na validação do {self.tracker}. Cookie provavelmente expirado (redirecionado para a página de login).[/bold red]') + return False + + except httpx.TimeoutException: + console.print(f'[bold red]Erro no {self.tracker}: Timeout (30s) ao tentar validar credenciais.[/bold red]') + return False + except httpx.HTTPStatusError as e: + console.print(f'[bold red]Erro HTTP ao validar credenciais do {self.tracker}: Status {e.response.status_code}. O site pode estar offline.[/bold red]') + return False + except httpx.RequestError as e: + console.print(f'[bold red]Erro de rede ao validar credenciais do {self.tracker}: {e.__class__.__name__}. Verifique sua conexão.[/bold red]') + return False + + def load_localized_data(self, meta): + localized_data_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/tmdb_localized_data.json" + + if os.path.isfile(localized_data_file): + with open(localized_data_file, "r", encoding="utf-8") as f: + self.tmdb_data = json.load(f) + else: + self.tmdb_data = {} + + async def main_tmdb_data(self, meta): + brazil_data_in_meta = self.tmdb_data.get('pt-BR', {}).get('main') + if brazil_data_in_meta: + return brazil_data_in_meta + + data = await get_tmdb_localized_data(meta, data_type='main', language='pt-BR', append_to_response='credits,videos,content_ratings') + self.load_localized_data(meta) + + return data + + async def season_tmdb_data(self, meta): + brazil_data_in_meta = self.tmdb_data.get('pt-BR', {}).get('season') + if brazil_data_in_meta: + return brazil_data_in_meta + + data = await get_tmdb_localized_data(meta, data_type='season', language='pt-BR', append_to_response='') + self.load_localized_data(meta) + + return data + + async def episode_tmdb_data(self, meta): + brazil_data_in_meta = self.tmdb_data.get('pt-BR', {}).get('episode') + if brazil_data_in_meta: + return brazil_data_in_meta + + data = await get_tmdb_localized_data(meta, data_type='episode', language='pt-BR', append_to_response='') + self.load_localized_data(meta) + + return data + + async def get_container(self, meta): + if meta['is_disc'] == 'BDMV': + return '5' + elif meta['is_disc'] == 'DVD': + return '15' + + try: + general_track = next(t for t in meta['mediainfo']['media']['track'] if t['@type'] == 'General') + file_extension = general_track.get('FileExtension', '').lower() + if file_extension == 'mkv': + return '6' + elif file_extension == 'mp4': + return '8' + except (StopIteration, AttributeError, TypeError): + return None + return None + + async def get_type(self, meta): + bd_disc_map = {'BD25': '40', 'BD50': '41', 'BD66': '42', 'BD100': '43'} + standard_map = {'ENCODE': '9', 'REMUX': '39', 'WEBDL': '23', 'WEBRIP': '38', 'BDRIP': '8', 'DVDRIP': '3'} + dvd_map = {'DVD5': '45', 'DVD9': '46'} + + if meta['type'] == 'DISC': + if meta['is_disc'] == 'HDDVD': + return 15 + + if meta['is_disc'] == 'DVD': + dvd_size = meta['dvd_size'] + type_id = dvd_map[dvd_size] + if type_id: + return type_id + + disctype = meta['disctype'] + if disctype in bd_disc_map: + return bd_disc_map[disctype] + + try: + size_in_gb = meta['bdinfo']['size'] + except (KeyError, IndexError, TypeError): + size_in_gb = 0 + + if size_in_gb > 66: + return '43' # BD100 + elif size_in_gb > 50: + return '42' # BD66 + elif size_in_gb > 25: + return '41' # BD50 + else: + return '40' # BD25 + else: + return standard_map.get(meta['type'], '0') + + async def get_languages(self, meta): + if meta.get('anime'): + if meta['category'] == 'MOVIE': + type_ = '116' + if meta['category'] == 'TV': + type_ = '118' + + anime_language = self.anime_language_map.get(meta.get('original_language', '').lower(), '6') + + if self.get_audio(meta) in ('2', '3', '4'): + lang = '8' + else: + lang = self.language_map.get(meta.get('original_language', '').lower(), '11') + + return { + 'type': type_, + 'idioma': anime_language, + 'lang': lang + } + + async def get_audio(self, meta): + subtitles = '1' + dual_audio = '2' + dubbed = '3' + national = '4' + original = '7' + + portuguese_languages = {'portuguese', 'português', 'pt'} + + has_pt_subs = (await self.get_subtitle(meta)) == 'Embutida' + + audio_languages = {lang.lower() for lang in meta.get('audio_languages', [])} + has_pt_audio = any(lang in portuguese_languages for lang in audio_languages) + + original_lang = meta.get('original_language', '').lower() + is_original_pt = original_lang in portuguese_languages + + if has_pt_audio: + if is_original_pt: + return national + elif len(audio_languages - portuguese_languages) > 0: + return dual_audio + else: + return dubbed + elif has_pt_subs: + return subtitles + else: + return original + + async def get_subtitle(self, meta): + portuguese_languages = {'portuguese', 'português', 'pt'} + + found_languages = {lang.lower() for lang in meta.get('subtitle_languages', [])} + + if any(lang in portuguese_languages for lang in found_languages): + return 'Embutida' + return 'S_legenda' + + async def get_resolution(self, meta): + if meta.get('is_disc') == 'BDMV': + resolution_str = meta.get('resolution', '') + try: + height_num = int(resolution_str.lower().replace('p', '').replace('i', '')) + height = str(height_num) + + width_num = round((16 / 9) * height_num) + width = str(width_num) + except (ValueError, TypeError): + pass + + else: + video_mi = meta['mediainfo']['media']['track'][1] + width = video_mi['Width'] + height = video_mi['Height'] + + return { + 'width': width, + 'height': height + } + + async def get_video_codec(self, meta): + codec_video_map = { + 'MPEG-4': '31', 'AV1': '29', 'AVC': '30', 'DivX': '9', + 'H264': '17', 'H265': '18', 'HEVC': '27', 'M4V': '20', + 'MPEG-1': '10', 'MPEG-2': '11', 'RMVB': '12', 'VC-1': '21', + 'VP6': '22', 'VP9': '23', 'WMV': '13', 'XviD': '15' + } + + codec_video = None + video_encode_raw = meta.get('video_encode') + + if video_encode_raw and isinstance(video_encode_raw, str): + video_encode_clean = video_encode_raw.strip().lower() + if '264' in video_encode_clean: + codec_video = 'H264' + elif '265' in video_encode_clean: + codec_video = 'HEVC' + + if not codec_video: + codec_video = meta.get('video_codec') + + codec_id = codec_video_map.get(codec_video, '16') + + is_hdr = bool(meta.get('hdr')) + + if is_hdr: + if codec_video in ('HEVC', 'H265'): + return '28' + if codec_video in ('AVC', 'H264'): + return '32' + + return codec_id + + async def get_audio_codec(self, meta): + audio_type = (meta['audio'] or '').upper() + + codec_map = { + 'ATMOS': '43', + 'DTS:X': '25', + 'DTS-HD MA': '24', + 'DTS-HD': '23', + 'TRUEHD': '29', + 'DD+': '26', + 'DD': '11', + 'DTS': '12', + 'FLAC': '13', + 'LPCM': '21', + 'PCM': '28', + 'AAC': '10', + 'OPUS': '27', + 'MPEG': '17' + } + + for key, code in codec_map.items(): + if key in audio_type: + return code + + return '20' + + async def get_title(self, meta): + tmdb_ptbr_data = await self.main_tmdb_data(meta) + name = meta['title'] + base_name = name + + if meta['category'] == 'TV': + tv_title_ptbr = tmdb_ptbr_data['name'] + if tv_title_ptbr and tv_title_ptbr.lower() != name.lower(): + base_name = f"{tv_title_ptbr} ({name})" + + return f"{base_name} - {meta.get('season', '')}{meta.get('episode', '')}" + + else: + movie_title_ptbr = tmdb_ptbr_data['title'] + if movie_title_ptbr and movie_title_ptbr.lower() != name.lower(): + base_name = f"{movie_title_ptbr} ({name})" + + return f"{base_name}" + + async def build_description(self, meta): + main_tmdb = await self.main_tmdb_data(meta) + + season_tmdb = {} + episode_tmdb = {} + if meta['category'] == 'TV': + season_tmdb = await self.season_tmdb_data(meta) + if not meta.get('tv_pack', False): + episode_tmdb = await self.episode_tmdb_data(meta) + user_layout = await self.fetch_layout_data(meta) + fileinfo_dump = await self.media_info(meta) + + if not user_layout: + return '[center]Erro: Não foi possível carregar o layout da descrição.[/center]' + + layout_image = {k: v for k, v in user_layout.items() if k.startswith('BARRINHA_')} + description_parts = ['[center]'] + + async def append_section(key: str, content: str): + if content and (img := layout_image.get(key)): + description_parts.append(f'\n{await self.format_image(img)}') + description_parts.append(f'\n{content}\n') + + # Title + for i in range(1, 4): + description_parts.append(await self.format_image(layout_image.get(f'BARRINHA_CUSTOM_T_{i}'))) + description_parts.append(f"\n{await self.format_image(layout_image.get('BARRINHA_APRESENTA'))}\n") + description_parts.append(f"\n[size=3]{await self.get_title(meta)}[/size]\n") + + # Poster + poster_path = (season_tmdb or {}).get('poster_path') or (main_tmdb or {}).get('poster_path') or meta.get('tmdb_poster') + poster = f'https://image.tmdb.org/t/p/w500{poster_path}' if poster_path else '' + await append_section('BARRINHA_CAPA', await self.format_image(poster)) + + # Overview + overview = (season_tmdb or {}).get('overview') or (main_tmdb or {}).get('overview') + await append_section('BARRINHA_SINOPSE', overview) + + # Episode + if meta['category'] == 'TV' and episode_tmdb: + episode_name = episode_tmdb.get('name') + episode_overview = episode_tmdb.get('overview') + still_path = episode_tmdb.get('still_path') + + if episode_name and episode_overview and still_path: + still_url = f'https://image.tmdb.org/t/p/w300{still_path}' + description_parts.append(f'\n[size=4][b]Episódio:[/b] {episode_name}[/size]\n') + description_parts.append(f'\n{await self.format_image(still_url)}\n\n{episode_overview}\n') + + # Technical Sheet + if main_tmdb: + runtime = (episode_tmdb or {}).get('runtime') or main_tmdb.get('runtime') or meta.get('runtime') + formatted_runtime = None + if runtime: + h, m = divmod(runtime, 60) + formatted_runtime = f"{h} hora{'s' if h > 1 else ''} e {m:02d} minutos" if h > 0 else f"{m:02d} minutos" + + release_date = (episode_tmdb or {}).get('air_date') or (season_tmdb or {}).get('air_date') if meta['category'] != 'MOVIE' else main_tmdb.get('release_date') + + sheet_items = [ + f'Duração: {formatted_runtime}' if formatted_runtime else None, + f"País de Origem: {', '.join(c['name'] for c in main_tmdb.get('production_countries', []))}" if main_tmdb.get('production_countries') else None, + f"Gêneros: {', '.join(g['name'] for g in main_tmdb.get('genres', []))}" if main_tmdb.get('genres') else None, + f'Data de Lançamento: {await self.format_date(release_date)}' if release_date else None, + f"Site: [url={main_tmdb.get('homepage')}]Clique aqui[/url]" if main_tmdb.get('homepage') else None + ] + await append_section('BARRINHA_FICHA_TECNICA', '\n'.join(filter(None, sheet_items))) + + # Production Companies + if main_tmdb and main_tmdb.get('production_companies'): + prod_parts = ['[size=4][b]Produtoras[/b][/size]'] + for p in main_tmdb.get('production_companies', []): + logo_path = p.get('logo_path') + logo = await self.format_image(f'https://image.tmdb.org/t/p/w45{logo_path}') if logo_path else '' + + prod_parts.append(f"{logo}[size=2] - [b]{p.get('name', '')}[/b][/size]" if logo else f"[size=2][b]{p.get('name', '')}[/b][/size]") + description_parts.append('\n' + '\n'.join(prod_parts) + '\n') + + # Cast + if meta['category'] == 'MOVIE': + cast_data = ((main_tmdb or {}).get('credits') or {}).get('cast', []) + elif meta.get('tv_pack'): + cast_data = ((season_tmdb or {}).get('credits') or {}).get('cast', []) + else: + cast_data = ((episode_tmdb or {}).get('credits') or {}).get('cast', []) + await append_section('BARRINHA_ELENCO', await self.build_cast_bbcode(cast_data)) + + # Seasons + if meta['category'] == 'TV' and main_tmdb and main_tmdb.get('seasons'): + seasons_content = [] + for seasons in main_tmdb.get('seasons', []): + season_name = seasons.get('name', f"Temporada {seasons.get('season_number')}").strip() + poster_temp = await self.format_image(f"https://image.tmdb.org/t/p/w185{seasons.get('poster_path')}") if seasons.get('poster_path') else '' + overview_temp = f"\n\nSinopse:\n{seasons.get('overview')}" if seasons.get('overview') else '' + + inner_content_parts = [] + air_date = seasons.get('air_date') + if air_date: + inner_content_parts.append(f'Data: {await self.format_date(air_date)}') + + episode_count = seasons.get('episode_count') + if episode_count is not None: + inner_content_parts.append(f'Episódios: {episode_count}') + + inner_content_parts.append(poster_temp) + inner_content_parts.append(overview_temp) + + inner_content = '\n'.join(inner_content_parts) + seasons_content.append(f'\n[spoiler={season_name}]{inner_content}[/spoiler]\n') + await append_section('BARRINHA_EPISODIOS', ''.join(seasons_content)) + + # Ratings + ratings_list = user_layout.get('Ratings', []) + if not ratings_list: + if imdb_rating := meta.get('imdb_info', {}).get('rating'): + ratings_list.append({'Source': 'Internet Movie Database', 'Value': f'{imdb_rating}/10'}) + if main_tmdb and (tmdb_rating := main_tmdb.get('vote_average')): + if not any(r.get('Source') == 'TMDb' for r in ratings_list): + ratings_list.append({'Source': 'TMDb', 'Value': f'{tmdb_rating:.1f}/10'}) + + criticas_key = 'BARRINHA_INFORMACOES' if meta['category'] == 'MOVIE' and 'BARRINHA_INFORMACOES' in layout_image else 'BARRINHA_CRITICAS' + await append_section(criticas_key, await self.build_ratings_bbcode(meta, ratings_list)) + + # MediaInfo/BDinfo + if fileinfo_dump: + description_parts.append(f'\n[spoiler=Informações do Arquivo]\n[left][font=Courier New]{fileinfo_dump}[/font][/left][/spoiler]\n') + + # Custom Bar + for i in range(1, 4): + description_parts.append(await self.format_image(layout_image.get(f'BARRINHA_CUSTOM_B_{i}'))) + description_parts.append('[/center]') + + # External description + desc = '' + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as f: + desc = f.read().strip() + desc = desc.replace('[user]', '').replace('[/user]', '') + desc = desc.replace('[align=left]', '').replace('[/align]', '') + desc = desc.replace('[align=right]', '').replace('[/align]', '') + desc = desc.replace('[alert]', '').replace('[/alert]', '') + desc = desc.replace('[note]', '').replace('[/note]', '') + desc = desc.replace('[h1]', '[u][b]').replace('[/h1]', '[/b][/u]') + desc = desc.replace('[h2]', '[u][b]').replace('[/h2]', '[/b][/u]') + desc = desc.replace('[h3]', '[u][b]').replace('[/h3]', '[/b][/u]') + desc = re.sub(r'(\[img=\d+)]', '[img]', desc, flags=re.IGNORECASE) + description_parts.append(desc) + + custom_description_header = self.config['DEFAULT'].get('custom_description_header', '') + if custom_description_header: + description_parts.append(custom_description_header + '\n') + + if self.signature: + description_parts.append(self.signature) + + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + with open(final_desc_path, 'w', encoding='utf-8') as descfile: + final_description = '\n'.join(filter(None, description_parts)) + descfile.write(final_description) + + return final_description + + async def get_trailer(self, meta): + tmdb_data = await self.main_tmdb_data(meta) + video_results = tmdb_data.get('videos', {}).get('results', []) + youtube_code = video_results[-1].get('key', '') if video_results else '' + if youtube_code: + youtube = f'http://www.youtube.com/watch?v={youtube_code}' + else: + youtube = meta.get('youtube') or '' + + return youtube + + async def get_tags(self, meta): + tmdb_data = await self.main_tmdb_data(meta) + + tags = ', '.join( + g.get('name', '') + for g in tmdb_data.get('genres', []) + if isinstance(g.get('name'), str) and g.get('name').strip() + ) + + if not tags: + tags = meta.get('genre') or await asyncio.to_thread( + input, f'Digite os gêneros (no formato do {self.tracker}): ' + ) + + return tags + + async def _fetch_file_info(self, torrent_id, torrent_link, size): + ''' + Helper function to fetch file info for a single release in parallel. + ''' + file_page_url = f'{self.base_url}/torrents-arquivos.php?id={torrent_id}' + filename = 'N/A' + + try: + file_page_response = await self.session.get(file_page_url, timeout=15) + file_page_response.raise_for_status() + file_page_soup = BeautifulSoup(file_page_response.text, 'html.parser') + file_li_tag = file_page_soup.find('li', class_='list-group-item') + + if file_li_tag and file_li_tag.contents: + filename = file_li_tag.contents[0].strip() + + except Exception as e: + console.print(f'[bold red]Falha ao obter nome do arquivo para ID {torrent_id}: {e}[/bold red]') + + return { + 'name': filename, + 'size': size, + 'link': torrent_link + } + + async def search_existing(self, meta, disctype): + found_items = [] + if meta.get('anime'): + search_name = await self.get_title(meta) + search_query = search_name.replace(' ', '+') + search_url = f'{self.base_url}/torrents-search.php?search={search_query}' + + elif meta['category'] == 'MOVIE': + search_url = f"{self.base_url}/busca-filmes.php?search=&imdb={meta['imdb_info']['imdbID']}" + + elif meta['category'] == 'TV': + search_url = f"{self.base_url}/busca-series.php?search={meta.get('season', '')}{meta.get('episode', '')}&imdb={meta['imdb_info']['imdbID']}" + + else: + return found_items + + try: + response = await self.session.get(search_url, timeout=30) + response.raise_for_status() + soup = BeautifulSoup(response.text, 'html.parser') + releases = soup.find_all('li', class_='list-group-item dark-gray') + except Exception as e: + console.print(f'[bold red]Falha ao acessar a página de busca do ASC: {e}[/bold red]') + return found_items + + if not releases: + return found_items + + name_search_tasks = [] + + for release in releases: + details_link_tag = release.find('a', href=lambda href: href and 'torrents-details.php?id=' in href) + torrent_link = details_link_tag.get('href', '') if details_link_tag else '' + size_tag = release.find('span', text=lambda t: t and ('GB' in t.upper() or 'MB' in t.upper()), class_='badge-info') + size = size_tag.get_text(strip=True).strip() if size_tag else '' + + try: + badges = release.find_all('span', class_='badge') + disc_types = ['BD25', 'BD50', 'BD66', 'BD100', 'DVD5', 'DVD9'] + is_disc = any(badge.text.strip().upper() in disc_types for badge in badges) + + if is_disc: + name, year, resolution, disk_type, video_codec, audio_codec = meta['title'], 'N/A', 'N/A', 'N/A', 'N/A', 'N/A' + video_codec_terms = ['MPEG-4', 'AV1', 'AVC', 'H264', 'H265', 'HEVC', 'MPEG-1', 'MPEG-2', 'VC-1', 'VP6', 'VP9'] + audio_codec_terms = ['DTS', 'AC3', 'DDP', 'E-AC-3', 'TRUEHD', 'ATMOS', 'LPCM', 'AAC', 'FLAC'] + + for badge in badges: + badge_text = badge.text.strip() + badge_text_upper = badge_text.upper() + + if badge_text.isdigit() and len(badge_text) == 4: + year = badge_text + elif badge_text_upper in ['4K', '2160P', '1080P', '720P', '480P']: + resolution = '2160p' if badge_text_upper == '4K' else badge_text + elif any(term in badge_text_upper for term in video_codec_terms): + video_codec = badge_text + elif any(term in badge_text_upper for term in audio_codec_terms): + audio_codec = badge_text + elif any(term in badge_text_upper for term in disc_types): + disk_type = badge_text + + name = f'{name} {year} {resolution} {disk_type} {video_codec} {audio_codec}' + dupe_entry = { + 'name': name, + 'size': size, + 'link': torrent_link + } + + found_items.append(dupe_entry) + + else: + if not details_link_tag: + continue + + torrent_id = details_link_tag['href'].split('id=')[-1] + name_search_tasks.append(self._fetch_file_info(torrent_id, torrent_link, size)) + + except Exception as e: + console.print(f'[bold red]Falha ao processar um release da lista: {e}[/bold red]') + continue + + if name_search_tasks: + parallel_results = await asyncio.gather(*name_search_tasks) + found_items.extend(parallel_results) + + return found_items + + async def get_upload_url(self, meta): + if meta.get('anime'): + return f'{self.base_url}/enviar-anime.php' + elif meta['category'] == 'MOVIE': + return f'{self.base_url}/enviar-filme.php' + else: + return f'{self.base_url}/enviar-series.php' + + async def format_image(self, url): + return f'[img]{url}[/img]' if url else '' + + async def format_date(self, date_str): + if not date_str or date_str == 'N/A': + return 'N/A' + for fmt in ('%Y-%m-%d', '%d %b %Y'): + try: + return datetime.strptime(str(date_str), fmt).strftime('%d/%m/%Y') + except (ValueError, TypeError): + continue + return str(date_str) + + async def media_info(self, meta): + if meta.get('is_disc') == 'BDMV': + summary_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt" + if os.path.exists(summary_path): + with open(summary_path, 'r', encoding='utf-8') as f: + return f.read() + if not meta.get('is_disc'): + video_file = meta['filelist'][0] + template_path = os.path.abspath(f"{meta['base_dir']}/data/templates/MEDIAINFO.txt") + if os.path.exists(template_path): + mi_output = MediaInfo.parse( + video_file, + output='STRING', + full=False, + mediainfo_options={'inform': f'file://{template_path}'} + ) + return str(mi_output).replace('\r', '') + + return None + + async def fetch_layout_data(self, meta): + url = f'{self.base_url}/search.php' + + async def _fetch(payload): + try: + response = await self.session.post(url, data=payload, timeout=20) + response.raise_for_status() + return response.json().get('ASC') + except Exception: + return None + + primary_payload = {'imdb': meta['imdb_info']['imdbID'], 'layout': self.layout} + layout_data = await _fetch(primary_payload) + if layout_data: + return layout_data + + # Fallback + fallback_payload = {'imdb': 'tt0013442', 'layout': self.layout} + return await _fetch(fallback_payload) + + async def build_ratings_bbcode(self, meta, ratings_list): + if not ratings_list: + return '' + + ratings_map = { + 'Internet Movie Database': '[img]https://i.postimg.cc/Pr8Gv4RQ/IMDB.png[/img]', + 'Rotten Tomatoes': '[img]https://i.postimg.cc/rppL76qC/rotten.png[/img]', + 'Metacritic': '[img]https://i.postimg.cc/SKkH5pNg/Metacritic45x45.png[/img]', + 'TMDb': '[img]https://i.postimg.cc/T13yyzyY/tmdb.png[/img]' + } + parts = [] + for rating in ratings_list: + source = rating.get('Source') + value = rating.get('Value', '').strip() + img_tag = ratings_map.get(source) + if not img_tag: + continue + + if source == 'Internet Movie Database': + parts.append(f"\n[url=https://www.imdb.com/title/{meta['imdb_info']['imdbID']}]{img_tag}[/url]\n[b]{value}[/b]\n") + elif source == 'TMDb': + parts.append(f"[url=https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}]{img_tag}[/url]\n[b]{value}[/b]\n") + else: + parts.append(f"{img_tag}\n[b]{value}[/b]\n") + return "\n".join(parts) + + async def build_cast_bbcode(self, cast_list): + if not cast_list: + return '' + + parts = [] + for person in cast_list[:10]: + profile_path = person.get('profile_path') + profile_url = f'https://image.tmdb.org/t/p/w45{profile_path}' if profile_path else 'https://i.imgur.com/eCCCtFA.png' + tmdb_url = f"https://www.themoviedb.org/person/{person.get('id')}?language=pt-BR" + img_tag = await self.format_image(profile_url) + character_info = f"({person.get('name', '')}) como {person.get('character', '')}" + parts.append(f'[url={tmdb_url}]{img_tag}[/url]\n[size=2][b]{character_info}[/b][/size]\n') + return ''.join(parts) + + async def get_requests(self, meta): + if not self.config['DEFAULT'].get('search_requests', False) and not meta.get('search_requests', False): + return False + else: + try: + category = meta['category'] + if meta.get('anime'): + if category == 'TV': + category = 118 + if category == 'MOVIE': + category = 116 + else: + if category == 'TV': + category = 120 + if category == 'MOVIE': + category = 119 + + query = meta['title'] + + search_url = f'{self.base_url}/pedidos.php?search={query}&category={category}' + + response = await self.session.get(search_url) + response.raise_for_status() + response_results_text = response.text + + soup = BeautifulSoup(response_results_text, 'html.parser') + + request_rows = soup.select('.table-responsive table tr') + + results = [] + for row in request_rows: + all_tds = row.find_all('td') + if not all_tds or len(all_tds) < 6: + continue + + info_cell = all_tds[1] + link_element = info_cell.select_one('a[href*="pedidos.php?action=ver"]') + if not link_element: + continue + + name = link_element.text.strip() + link = link_element.get('href') + + reward_td = all_tds[4] + reward = reward_td.text.strip() + + results.append({ + 'Name': name, + 'Reward': reward, + 'Link': link, + }) + + if results: + message = f'\n{self.tracker}: [bold yellow]Seu upload pode atender o(s) seguinte(s) pedido(s), confira:[/bold yellow]\n\n' + for r in results: + message += f"[bold green]Nome:[/bold green] {r['Name']}\n" + message += f"[bold green]Recompensa:[/bold green] {r['Reward']}\n" + message += f"[bold green]Link:[/bold green] {self.base_url}/{r['Link']}\n\n" + console.print(message) + + return results + + except Exception as e: + console.print(f'[bold red]Ocorreu um erro ao buscar pedido(s) no {self.tracker}: {e}[/bold red]') + import traceback + console.print(traceback.format_exc()) + return [] + + async def fetch_data(self, meta): + self.load_localized_data(meta) + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + main_tmdb = await self.main_tmdb_data(meta) + resolution = await self.get_resolution(meta) + + data = { + 'ano': str(meta['year']), + 'audio': await self.get_audio(meta), + 'capa': f"https://image.tmdb.org/t/p/w500{main_tmdb.get('poster_path') or meta.get('tmdb_poster')}", + 'codecaudio': await self.get_audio_codec(meta), + 'codecvideo': await self.get_video_codec(meta), + 'descr': await self.build_description(meta), + 'extencao': await self.get_container(meta), + 'genre': await self.get_tags(meta), + 'imdb': meta['imdb_info']['imdbID'], + 'altura': resolution['height'], + 'largura': resolution['width'], + 'lang': self.language_map.get(meta.get('original_language', '').lower(), '11'), + 'layout': self.layout, + 'legenda': await self.get_subtitle(meta), + 'name': await self.get_title(meta), + 'qualidade': await self.get_type(meta), + 'takeupload': 'yes', + 'tresd': '1' if meta.get('3d') else '2', + 'tube': await self.get_trailer(meta), + } + + if meta.get('anime'): + anime_info = await self.get_languages(meta) + data.update = { + 'idioma': anime_info['idioma'], + 'lang': anime_info['lang'], + 'type': anime_info['type'], + } + + # Internal + if self.config['TRACKERS'][self.tracker].get('internal', False) is True: + data.update({ + 'internal': 'yes', + }) + + # Screenshots + for i, img in enumerate(meta.get('image_list', [])[:4]): + data[f'screens{i+1}'] = img.get('raw_url') + + return data + + async def upload(self, meta, disctype): + await self.load_cookies(meta) + data = await self.fetch_data(meta) + requests = await self.get_requests(meta) + await self.edit_torrent(meta, self.tracker, self.source_flag) + status_message = '' + + if not meta.get('debug', False): + torrent_id = '' + upload_url = await self.get_upload_url(meta) + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = {'torrent': (f"{self.tracker}.{meta.get('infohash', '')}.placeholder.torrent", torrent_file, 'application/x-bittorrent')} + + response = await self.session.post(upload_url, data=data, files=files, timeout=60) + + if 'torrents-details.php?id=' in response.text: + status_message = 'Enviado com sucesso.' + + # Find the torrent id + match = re.search(r'torrents-details\.php\?id=(\d+)', response.text) + if match: + torrent_id = match.group(1) if match else None + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + if requests: + status_message += ' Seu upload pode atender a pedidos existentes, verifique os logs anteriores do console.' + + # Approval + should_approve = await self.get_approval(meta) + if should_approve: + await self.auto_approval(torrent_id) + + else: + status_message = 'O upload pode ter falhado, verifique. ' + response_save_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(response_save_path, 'w', encoding='utf-8') as f: + f.write(response.text) + print(f'Falha no upload, a resposta HTML foi salva em: {response_save_path}') + meta['skipping'] = f'{self.tracker}' + return + + await self.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce, self.torrent_url + torrent_id) + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading.' + + meta['tracker_status'][self.tracker]['status_message'] = status_message + + async def auto_approval(self, torrent_id): + try: + approval_url = f'{self.base_url}/uploader_app.php?id={torrent_id}' + approval_response = await self.session.get(approval_url, timeout=30) + approval_response.raise_for_status() + except Exception as e: + console.print(f'[bold red]Erro durante a tentativa de aprovação automática: {e}[/bold red]') + + async def get_approval(self, meta): + if not self.config['TRACKERS'][self.tracker].get('uploader_status', False): + return False + + if meta.get('modq', False): + print('Enviando para a fila de moderação.') + return False + + return True diff --git a/src/trackers/AVISTAZ_NETWORK.py b/src/trackers/AVISTAZ_NETWORK.py new file mode 100644 index 000000000..07903885d --- /dev/null +++ b/src/trackers/AVISTAZ_NETWORK.py @@ -0,0 +1,1180 @@ +import asyncio +import bbcode +import bencodepy +import hashlib +import http.cookiejar +import httpx +import json +import os +import platform +import re +import uuid +from bs4 import BeautifulSoup +from pathlib import Path +from src.console import console +from src.languages import process_desc_language +from src.trackers.COMMON import COMMON +from tqdm.asyncio import tqdm +from typing import Optional +from urllib.parse import urlparse + + +class AZTrackerBase(): + def __init__(self, config, tracker_name): + self.config = config + self.tracker = tracker_name + self.common = COMMON(config) + + tracker_config = self.config['TRACKERS'][self.tracker] + self.base_url = tracker_config.get('base_url') + self.announce_url = tracker_config.get('announce_url') + self.source_flag = tracker_config.get('source_flag') + + self.auth_token = None + self.session = httpx.AsyncClient(headers={ + 'User-Agent': f"Upload Assistant/2.3 ({platform.system()} {platform.release()})" + }, timeout=60.0) + self.signature = '' + self.media_code = '' + + def get_resolution(self, meta): + resolution = '' + width, height = None, None + + try: + if meta.get('is_disc') == 'BDMV': + resolution_str = meta.get('resolution', '') + height_num = int(resolution_str.lower().replace('p', '').replace('i', '')) + height = str(height_num) + width = str(round((16 / 9) * height_num)) + else: + tracks = meta.get('mediainfo', {}).get('media', {}).get('track', []) + if len(tracks) > 1: + video_mi = tracks[1] + width = video_mi.get('Width') + height = video_mi.get('Height') + except (ValueError, TypeError, KeyError, IndexError): + return '' + + if width and height: + resolution = f'{width}x{height}' + + return resolution + + def get_video_quality(self, meta): + resolution = meta.get('resolution') + + if self.tracker != 'PHD': + resolution_int = int(resolution.lower().replace('p', '').replace('i', '')) + if resolution_int < 720 or meta.get('sd', False): + return '1' + + keyword_map = { + '1080i': '7', + '1080p': '3', + '2160p': '6', + '4320p': '8', + '720p': '2', + } + + return keyword_map.get(resolution.lower()) + + async def get_media_code(self, meta): + self.media_code = '' + + if meta['category'] == 'MOVIE': + category = '1' + elif meta['category'] == 'TV': + category = '2' + else: + return False + + search_term = '' + imdb_info = meta.get('imdb_info', {}) + imdb_id = imdb_info.get('imdbID') if isinstance(imdb_info, dict) else None + tmdb_id = meta.get('tmdb') + title = meta['title'] + + if imdb_id: + search_term = imdb_id + else: + search_term = title + + ajax_url = f'{self.base_url}/ajax/movies/{category}?term={search_term}' + + headers = { + 'Referer': f"{self.base_url}/upload/{meta['category'].lower()}", + 'X-Requested-With': 'XMLHttpRequest' + } + + for attempt in range(2): + try: + if attempt == 1: + console.print(f'{self.tracker}: Trying to search again by ID after adding to media to database...\n') + await asyncio.sleep(5) # Small delay to ensure the DB has been updated + + response = await self.session.get(ajax_url, headers=headers) + response.raise_for_status() + data = response.json() + + if data.get('data'): + match = None + for item in data['data']: + if imdb_id and item.get('imdb') == imdb_id: + match = item + break + elif not imdb_id and item.get('tmdb') == str(tmdb_id): + match = item + break + + if match: + self.media_code = str(match['id']) + if attempt == 1: + console.print(f"{self.tracker}: [green]Found new ID at:[/green] {self.base_url}/{meta['category'].lower()}/{self.media_code}") + return True + + except Exception as e: + console.print(f'{self.tracker}: Error while trying to fetch media code in attempt {attempt + 1}: {e}') + break + + if attempt == 0 and not self.media_code: + console.print(f"\n{self.tracker}: The media [[yellow]IMDB:{imdb_id}[/yellow]] [[blue]TMDB:{tmdb_id}[/blue]] appears to be missing from the site's database.") + user_choice = input(f"{self.tracker}: Do you want to add it to the site database? (y/n): \n").lower() + + if user_choice in ['y', 'yes']: + added_successfully = await self.add_media_to_db(meta, title, category, imdb_id, tmdb_id) + if not added_successfully: + console.print(f'{self.tracker}: Failed to add media. Aborting.') + break + else: + console.print(f'{self.tracker}: User chose not to add media. Aborting.') + break + + if not self.media_code: + console.print(f'{self.tracker}: Unable to get media code.') + + return bool(self.media_code) + + async def add_media_to_db(self, meta, title, category, imdb_id, tmdb_id): + data = { + '_token': self.auth_token, + 'type_id': category, + 'title': title, + 'imdb_id': imdb_id if imdb_id else '', + 'tmdb_id': tmdb_id if tmdb_id else '', + } + + if meta['category'] == 'TV': + tvdb_id = meta.get('tvdb') + if tvdb_id: + data['tvdb_id'] = str(tvdb_id) + + url = f"{self.base_url}/add/{meta['category'].lower()}" + + headers = { + 'Referer': f'{self.base_url}/upload', + } + + try: + console.print(f'{self.tracker}: Trying to add to database...') + response = await self.session.post(url, data=data, headers=headers) + if response.status_code == 302: + console.print(f'{self.tracker}: The attempt to add the media to the database appears to have been successful..') + return True + else: + console.print(f'{self.tracker}: Error adding media to the database. Status: {response.status_code}') + return False + except Exception as e: + console.print(f'{self.tracker}: Exception when trying to add media to the database: {e}') + return False + + async def load_cookies(self, meta): + cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + self.cookie_jar = http.cookiejar.MozillaCookieJar(cookie_file) + + try: + self.cookie_jar.load(ignore_discard=True, ignore_expires=True) + except FileNotFoundError: + console.print(f'{self.tracker}: [bold red]Cookie file for {self.tracker} not found: {cookie_file}[/bold red]') + + self.session.cookies = self.cookie_jar + + async def save_cookies(self): + # They seem to change their cookies frequently, we need to update the .txt + if self.cookie_jar is None: + console.print(f'{self.tracker}: Cookie jar not initialized, cannot save cookies.') + return + + try: + self.cookie_jar.save(ignore_discard=True, ignore_expires=True) + except Exception as e: + console.print(f'{self.tracker}: Failed to update the cookie file: {e}') + + async def validate_credentials(self, meta): + await self.load_cookies(meta) + try: + upload_page_url = f'{self.base_url}/upload' + response = await self.session.get(upload_page_url) + response.raise_for_status() + + if 'login' in str(response.url) or 'Forgot Your Password' in response.text or 'Page not found!' in response.text: + console.print(f'{self.tracker}: Validation failed. The cookie appears to be expired or invalid.') + return False + + auth_match = re.search(r'name="_token" content="([^"]+)"', response.text) + + if not auth_match: + console.print(f"{self.tracker}: Validation failed. Could not find 'auth' token on upload page.") + console.print('This can happen if the site HTML has changed or if the login failed silently..') + + failure_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + os.makedirs(os.path.dirname(failure_path), exist_ok=True) + with open(failure_path, 'w', encoding='utf-8') as f: + f.write(response.text) + console.print(f'The server response was saved to {failure_path} for analysis.') + return False + + await self.save_cookies() + return str(auth_match.group(1)) + + except httpx.TimeoutException: + console.print(f'{self.tracker}: Error in {self.tracker}: Timeout while trying to validate credentials.') + return False + except httpx.HTTPStatusError as e: + console.print(f'{self.tracker}: HTTP error validating credentials for {self.tracker}: Status {e.response.status_code}.') + return False + except httpx.RequestError as e: + console.print(f'{self.tracker}: Network error while validating credentials for {self.tracker}: {e.__class__.__name__}.') + return False + + async def search_existing(self, meta, disctype): + if self.config['TRACKERS'][self.tracker].get('check_for_rules', True): + warnings = await self.rules(meta) + if warnings: + console.print(f"{self.tracker}: [red]Rule check returned the following warning(s):[/red]\n\n{warnings}") + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + choice = input('Do you want to continue anyway? [y/N]: ').strip().lower() + if choice != 'y': + meta['skipping'] = f'{self.tracker}' + return + else: + meta['skipping'] = f'{self.tracker}' + return + + if not await self.get_media_code(meta): + console.print((f"{self.tracker}: This media is not registered, please add it to the database by following this link: {self.base_url}/add/{meta['category'].lower()}")) + meta['skipping'] = f'{self.tracker}' + return + + if meta.get('resolution') == '2160p': + resolution = 'UHD' + elif meta.get('resolution') in ('720p', '1080p'): + resolution = meta.get('resolution') + else: + resolution = 'all' + + page_url = f'{self.base_url}/movies/torrents/{self.media_code}?quality={resolution}' + + duplicates = [] + visited_urls = set() + + while page_url and page_url not in visited_urls: + visited_urls.add(page_url) + + try: + response = await self.session.get(page_url) + response.raise_for_status() + + soup = BeautifulSoup(response.text, 'html.parser') + + torrent_table = soup.find('table', class_='table-bordered') + if not torrent_table: + page_url = None + continue + + torrent_rows = torrent_table.find('tbody').find_all('tr', recursive=False) + + for row in torrent_rows: + name_tag = row.find('a', class_='torrent-filename') + name = name_tag.get_text(strip=True) if name_tag else '' + + torrent_link = name_tag.get('href') if name_tag and 'href' in name_tag.attrs else '' + if torrent_link: + match = re.search(r'/(\d+)', torrent_link) + if match: + torrent_link = f'{self.torrent_url}{match.group(1)}' + + cells = row.find_all('td') + size = '' + if len(cells) > 4: + size_span = cells[4].find('span') + size = size_span.get_text(strip=True) if size_span else cells[4].get_text(strip=True) + + dupe_entry = { + 'name': name, + 'size': size, + 'link': torrent_link + } + + duplicates.append(dupe_entry) + + next_page_tag = soup.select_one('a[rel=\'next\']') + if next_page_tag and 'href' in next_page_tag.attrs: + page_url = next_page_tag['href'] + else: + page_url = None + + except httpx.RequestError as e: + console.print(f'{self.tracker}: Failed to search for duplicates. {e.request.url}: {e}') + return duplicates + + return duplicates + + async def get_cat_id(self, category_name): + category_id = { + 'MOVIE': '1', + 'TV': '2', + }.get(category_name, '0') + return category_id + + async def get_file_info(self, meta): + info_file_path = '' + if meta.get('is_disc') == 'BDMV': + info_file_path = f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/BD_SUMMARY_00.txt" + else: + info_file_path = f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/MEDIAINFO_CLEANPATH.txt" + + if os.path.exists(info_file_path): + with open(info_file_path, 'r', encoding='utf-8') as f: + return f.read() + + async def get_lang(self, meta): + self.language_map() + audio_ids = set() + subtitle_ids = set() + + if meta.get('is_disc', False): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + found_subs_strings = meta.get('subtitle_languages', []) + for lang_str in found_subs_strings: + target_id = self.lang_map.get(lang_str.lower()) + if target_id: + subtitle_ids.add(target_id) + + found_audio_strings = meta.get('audio_languages', []) + for lang_str in found_audio_strings: + target_id = self.lang_map.get(lang_str.lower()) + if target_id: + audio_ids.add(target_id) + else: + try: + media_info_path = f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/MediaInfo.json" + with open(media_info_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + tracks = data.get('media', {}).get('track', []) + + missing_audio_languages = [] + + for track in tracks: + track_type = track.get('@type') + language_code = track.get('Language') + + if not language_code: + if track_type == 'Audio': + missing_audio_languages.append(track) + continue + + target_id = self.lang_map.get(language_code.lower()) + + if not target_id and '-' in language_code: + primary_code = language_code.split('-')[0] + target_id = self.lang_map.get(primary_code.lower()) + + if target_id: + if track_type == 'Audio': + audio_ids.add(target_id) + elif track_type == 'Text': + subtitle_ids.add(target_id) + else: + if track_type == 'Audio': + missing_audio_languages.append(track) + + if missing_audio_languages: + console.print('No audio language/s found.') + console.print('You must enter (comma-separated) languages for all audio tracks, eg: English, Spanish: ') + user_input = console.input('[bold yellow]Enter languages: [/bold yellow]') + + langs = [lang.strip() for lang in user_input.split(',')] + for lang in langs: + target_id = self.lang_map.get(lang.lower()) + if target_id: + audio_ids.add(target_id) + + except FileNotFoundError: + print(f'Warning: MediaInfo.json not found for uuid {meta.get("uuid")}. No languages will be processed.') + except (json.JSONDecodeError, KeyError, TypeError) as e: + print(f'Error processing MediaInfo.json for uuid {meta.get("uuid")}: {e}') + + final_subtitle_ids = sorted(list(subtitle_ids)) + final_audio_ids = sorted(list(audio_ids)) + + return { + 'subtitles[]': final_subtitle_ids, + 'languages[]': final_audio_ids + } + + async def img_host(self, meta, referer, image_bytes: bytes, filename: str) -> Optional[str]: + upload_url = f'{self.base_url}/ajax/image/upload' + + headers = { + 'Referer': referer, + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json', + 'Origin': self.base_url, + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:141.0) Gecko/20100101 Firefox/141.0' + } + + data = { + '_token': self.auth_token, + 'qquuid': str(uuid.uuid4()), + 'qqfilename': filename, + 'qqtotalfilesize': str(len(image_bytes)) + } + + files = {'qqfile': (filename, image_bytes, 'image/png')} + + try: + response = await self.session.post(upload_url, headers=headers, data=data, files=files) + + if response.is_success: + json_data = response.json() + if json_data.get('success'): + image_id = json_data.get('imageId') + return str(image_id) + else: + error_message = json_data.get('error', 'Unknown image host error.') + print(f'{self.tracker}: Error uploading {filename}: {error_message}') + return None + else: + print(f'{self.tracker}: Error uploading {filename}: Status {response.status_code} - {response.text}') + return None + except Exception as e: + print(f'{self.tracker}: Exception when uploading {filename}: {e}') + return None + + async def get_screenshots(self, meta): + screenshot_dir = Path(meta['base_dir']) / 'tmp' / meta['uuid'] + local_files = sorted(screenshot_dir.glob('*.png')) + results = [] + + limit = 3 if meta.get('tv_pack', '') == 0 else 15 + + if local_files: + async def upload_local_file(path): + with open(path, 'rb') as f: + image_bytes = f.read() + return await self.img_host(meta, self.tracker, image_bytes, path.name) + + paths = local_files[:limit] if limit else local_files + + for path in tqdm( + paths, + total=len(paths), + desc=f'{self.tracker}: Uploading screenshots' + ): + result = await upload_local_file(path) + if result: + results.append(result) + + else: + image_links = [img.get('raw_url') for img in meta.get('image_list', []) if img.get('raw_url')] + + async def upload_remote_file(url): + try: + response = await self.session.get(url) + response.raise_for_status() + image_bytes = response.content + filename = os.path.basename(urlparse(url).path) or 'screenshot.png' + return await self.img_host(meta, self.tracker, image_bytes, filename) + except Exception as e: + print(f'Failed to process screenshot from URL {url}: {e}') + return None + + links = image_links[:limit] if limit else image_links + + for url in tqdm( + links, + total=len(links), + desc=f'{self.tracker}: Uploading screenshots' + ): + result = await upload_remote_file(url) + if result: + results.append(result) + + return results + + async def get_requests(self, meta): + if not self.config['DEFAULT'].get('search_requests', False) and not meta.get('search_requests', False): + return False + + else: + try: + category = meta.get('category').lower() + + if category == 'tv': + query = meta['title'] + f" {meta.get('season', '')}{meta.get('episode', '')}" + else: + query = meta['title'] + + search_url = f'{self.base_url}/requests?type={category}&search={query}&condition=new' + + response = await self.session.get(search_url) + response.raise_for_status() + response_results_text = response.text + + soup = BeautifulSoup(response_results_text, 'html.parser') + + request_rows = soup.select('.table-responsive table tbody tr') + + results = [] + for row in request_rows: + link_element = row.select_one('a.torrent-filename') + + if not link_element: + continue + + name = link_element.text.strip() + link = link_element.get('href') + + all_tds = row.find_all('td') + + reward = all_tds[5].text.strip() if len(all_tds) > 5 else 'N/A' + + results.append({ + 'Name': name, + 'Link': link, + 'Reward': reward + }) + + if results: + message = f'\n{self.tracker}: [bold yellow]Your upload may fulfill the following request(s), check it out:[/bold yellow]\n\n' + for r in results: + message += f"[bold green]Name:[/bold green] {r['Name']}\n" + message += f"[bold green]Reward:[/bold green] {r['Reward']}\n" + message += f"[bold green]Link:[/bold green] {r['Link']}\n\n" + console.print(message) + + return results + + except Exception as e: + console.print(f'{self.tracker}: An error occurred while fetching requests: {e}') + return [] + + async def fetch_tag_id(self, word): + tags_url = f'{self.base_url}/ajax/tags' + params = {'term': word} + + headers = { + 'Referer': f'{self.base_url}/upload', + 'X-Requested-With': 'XMLHttpRequest' + } + try: + response = await self.session.get(tags_url, headers=headers, params=params) + response.raise_for_status() + + json_data = response.json() + + for tag_info in json_data.get('data', []): + if tag_info.get('tag') == word: + return tag_info.get('id') + + except Exception as e: + print(f"An unexpected error occurred while processing the tag '{word}': {e}") + + return None + + async def get_tags(self, meta): + genres = meta.get('keywords', '') + if not genres: + return [] + + # divides by commas, cleans spaces and normalizes to lowercase + phrases = [re.sub(r'\s+', ' ', x.strip().lower()) for x in re.split(r',+', genres) if x.strip()] + + words_to_search = set(phrases) + + tasks = [self.fetch_tag_id(word) for word in words_to_search] + + tag_ids_results = await asyncio.gather(*tasks) + + tags = [str(tag_id) for tag_id in tag_ids_results if tag_id is not None] + + if meta.get('personalrelease', False): + if self.tracker == 'AZ': + tags.insert(0, '3773') + elif self.tracker == 'CZ': + tags.insert(0, '1594') + elif self.tracker == 'PHD': + tags.insert(0, '1448') + + if self.config['TRACKERS'][self.tracker].get('internal', False): + if self.tracker == 'AZ': + tags.insert(0, '943') + elif self.tracker == 'CZ': + tags.insert(0, '938') + elif self.tracker == 'PHD': + tags.insert(0, '415') + + return tags + + async def edit_desc(self, meta): + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + description_parts = [] + + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as file: + manual_desc = file.read() + + if manual_desc: + console.print('\n[green]Found existing description:[/green]\n') + print(manual_desc) + user_input = input('Do you want to use this description? (y/n): ') + + if user_input.lower() == 'y': + description_parts.append(manual_desc) + console.print('Using existing description.') + else: + console.print('Ignoring existing description.') + + raw_bbcode_desc = '\n\n'.join(filter(None, description_parts)) + + processed_desc, amount = re.subn( + r'\[center\]\[spoiler=.*? NFO:\]\[code\](.*?)\[/code\]\[/spoiler\]\[/center\]', + '', + raw_bbcode_desc, + flags=re.DOTALL + ) + if amount > 0: + console.print(f'{self.tracker}: Deleted {amount} NFO section(s) from description.') + + processed_desc, amount = re.subn(r'http[s]?://\S+|www\.\S+', '', processed_desc) + if amount > 0: + console.print(f'{self.tracker}: Deleted {amount} Link(s) from description.') + + bbcode_tags_pattern = r'\[/?(size|align|left|center|right|img|table|tr|td|spoiler|url)[^\]]*\]' + processed_desc, amount = re.subn( + bbcode_tags_pattern, + '', + processed_desc, + flags=re.IGNORECASE + ) + if amount > 0: + console.print(f'{self.tracker}: Deleted {amount} BBCode tag(s) from description.') + + final_html_desc = bbcode.render_html(processed_desc) + + with open(final_desc_path, 'w', encoding='utf-8') as f: + f.write(final_html_desc) + + return final_html_desc + + async def create_task_id(self, meta): + await self.get_media_code(meta) + data = { + '_token': self.auth_token, + 'type_id': await self.get_cat_id(meta['category']), + 'movie_id': self.media_code, + 'media_info': await self.get_file_info(meta), + } + + if self.tracker == 'AZ': + default_announce = 'https://tracker.avistaz.to/announce' + elif self.tracker == 'CZ': + default_announce = 'https://tracker.cinemaz.to/announce' + elif self.tracker == 'PHD': + default_announce = 'https://tracker.privatehd.to/announce' + + if not meta.get('debug', False): + try: + await self.common.edit_torrent(meta, self.tracker, self.source_flag, announce_url=default_announce) + upload_url_step1 = f"{self.base_url}/upload/{meta['category'].lower()}" + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = {'torrent_file': (os.path.basename(torrent_path), torrent_file, 'application/x-bittorrent')} + torrent_data = bencodepy.decode(torrent_file.read()) + info = bencodepy.encode(torrent_data[b'info']) + info_hash = hashlib.sha1(info).hexdigest() + + task_response = await self.session.post(upload_url_step1, data=data, files=files) + + if task_response.status_code == 302 and 'Location' in task_response.headers: + redirect_url = task_response.headers['Location'] + + match = re.search(r'/(\d+)$', redirect_url) + if not match: + console.print(f"{self.tracker}: Could not extract 'task_id' from redirect URL: {redirect_url}") + console.print(f'{self.tracker}: The cookie appears to be expired or invalid.') + + task_id = match.group(1) + + return { + 'task_id': task_id, + 'info_hash': info_hash, + 'redirect_url': redirect_url, + } + + else: + failure_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload_Step1.html" + with open(failure_path, 'w', encoding='utf-8') as f: + f.write(task_response.text) + status_message = f'''[red]Step 1 of upload failed to {self.tracker}. Status: {task_response.status_code}, URL: {task_response.url}[/red]. + [yellow]The HTML response was saved to '{failure_path}' for analysis.[/yellow]''' + + except Exception as e: + status_message = f'[red]An unexpected error occurred while uploading to {self.tracker}: {e}[/red]' + meta['skipping'] = f'{self.tracker}' + return + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading.' + + meta['tracker_status'][self.tracker]['status_message'] = status_message + + def edit_name(self, meta): + upload_name = meta.get('name').replace(meta['aka'], '').replace('Dubbed', '').replace('Dual-Audio', '') + + if self.tracker == 'PHD': + forbidden_terms = [ + r'\bLIMITED\b', + r'\bCriterion Collection\b', + r'\b\d{1,3}(?:st|nd|rd|th)\s+Anniversary Edition\b' + ] + for term in forbidden_terms: + upload_name = re.sub(term, '', upload_name, flags=re.IGNORECASE).strip() + + upload_name = re.sub(r'\bDirector[’\'`]s\s+Cut\b', 'DC', upload_name, flags=re.IGNORECASE) + upload_name = re.sub(r'\bExtended\s+Cut\b', 'Extended', upload_name, flags=re.IGNORECASE) + upload_name = re.sub(r'\bTheatrical\s+Cut\b', 'Theatrical', upload_name, flags=re.IGNORECASE) + upload_name = re.sub(r'\s{2,}', ' ', upload_name).strip() + + if meta.get('has_encode_settings', False): + upload_name = upload_name.replace('H.264', 'x264').replace('H.265', 'x265') + + tag_lower = meta['tag'].lower() + invalid_tags = ['nogrp', 'nogroup', 'unknown', '-unk-'] + + if meta['tag'] == '' or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + upload_name = re.sub(f'-{invalid_tag}', '', upload_name, flags=re.IGNORECASE) + + if self.tracker == 'CZ': + upload_name = f'{upload_name}-NoGroup' + if self.tracker == 'PHD': + upload_name = f'{upload_name}-NOGROUP' + + if meta['category'] == 'TV': + if not meta.get('no_year', False) and not meta.get('search_year', ''): + season_int = meta.get('season_int', 0) + season_info = meta.get('imdb_info', {}).get('seasons_summary', []) + + # Find the correct year for this specific season + season_year = None + if season_int and season_info: + for season_data in season_info: + if season_data.get('season') == season_int: + season_year = season_data.get('year') + break + + # Use the season-specific year if found, otherwise fall back to meta year + year_to_use = season_year if season_year else meta.get('year') + upload_name = upload_name.replace(meta['title'], f"{meta['title']} {year_to_use}", 1) + + if meta.get('type', '') == 'DVDRIP': + if meta.get('source', ''): + upload_name = upload_name.replace(meta['source'], '') + + return re.sub(r'\s{2,}', ' ', upload_name) + + def get_rip_type(self, meta): + source_type = str(meta.get('type', '') or '').strip().lower() + source = str(meta.get('source', '') or '').strip().lower() + is_disc = str(meta.get('is_disc', '') or '').strip().upper() + + if is_disc == 'BDMV': + return '15' + if is_disc == 'HDDVD': + return '4' + if is_disc == 'DVD': + return '4' + + if source == 'dvd' and source_type == 'remux': + return '17' + + if source_type == 'remux': + if source == 'dvd': + return '17' + if source in ('bluray', 'blu-ray'): + return '14' + + keyword_map = { + 'bdrip': '1', + 'brrip': '3', + 'encode': '2', + 'dvdrip': '5', + 'hdrip': '6', + 'hdtv': '7', + 'sdtv': '16', + 'vcd': '8', + 'vcdrip': '9', + 'vhsrip': '10', + 'vodrip': '11', + 'webdl': '12', + 'webrip': '13', + } + + return keyword_map.get(source_type.lower()) + + async def fetch_data(self, meta): + await self.load_cookies(meta) + task_info = await self.create_task_id(meta) + lang_info = await self.get_lang(meta) or {} + + data = { + '_token': meta[f'{self.tracker}_secret_token'], + 'torrent_id': '', + 'type_id': await self.get_cat_id(meta['category']), + 'file_name': self.edit_name(meta), + 'anon_upload': '', + 'description': await self.edit_desc(meta), + 'qqfile': '', + 'rip_type_id': self.get_rip_type(meta), + 'video_quality_id': self.get_video_quality(meta), + 'video_resolution': self.get_resolution(meta), + 'movie_id': self.media_code, + 'languages[]': lang_info.get('languages[]'), + 'subtitles[]': lang_info.get('subtitles[]'), + 'media_info': await self.get_file_info(meta), + 'tags[]': await self.get_tags(meta), + 'screenshots[]': [''], + } + + # TV + if meta.get('category') == 'TV': + data.update({ + 'tv_collection': '1' if meta.get('tv_pack') == 0 else '2', + 'tv_season': meta.get('season_int', ''), + 'tv_episode': meta.get('episode_int', ''), + }) + + anon = not (meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False)) + if anon: + data.update({ + 'anon_upload': '1' + }) + + if not meta.get('debug', False): + try: + self.upload_url_step2 = task_info.get('redirect_url') + + # task_id and screenshot cannot be called until Step 1 is completed + data.update({ + 'info_hash': task_info.get('info_hash'), + 'task_id': task_info.get('task_id'), + 'screenshots[]': await self.get_screenshots(meta) + }) + + except Exception as e: + console.print(f'{self.tracker}: An unexpected error occurred while uploading: {e}') + + return data + + async def check_data(self, meta, data): + if not meta.get('debug', False): + if len(data['screenshots[]']) < 3: + return f'UPLOAD FAILED: The {self.tracker} image host did not return the minimum number of screenshots.' + return False + + async def upload(self, meta, disctype): + data = await self.fetch_data(meta) + requests = await self.get_requests(meta) + status_message = '' + + issue = await self.check_data(meta, data) + if issue: + status_message = f'data error - {issue}' + else: + if not meta.get('debug', False): + response = await self.session.post(self.upload_url_step2, data=data) + if response.status_code == 302: + torrent_url = response.headers['Location'] + + # Even if you are uploading, you still need to download the .torrent from the website + # because it needs to be registered as a download before you can start seeding + download_url = torrent_url.replace('/torrent/', '/download/torrent/') + register_download = await self.session.get(download_url) + if register_download.status_code != 200: + status_message = ( + f'data error - Unable to register your upload in your download history, please go to the URL and download the torrent file before you can start seeding: {torrent_url}\n' + f'Error: {register_download.status_code}' + ) + meta['tracker_status'][self.tracker]['status_message'] = status_message + return + + await self.common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce_url, torrent_url) + + status_message = 'Torrent uploaded successfully.' + + match = re.search(r'/torrent/(\d+)', torrent_url) + if match: + torrent_id = match.group(1) + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + if requests: + status_message += ' Your upload may fulfill existing requests, check prior console logs.' + + else: + failure_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload_Step2.html" + with open(failure_path, 'w', encoding='utf-8') as f: + f.write(response.text) + + status_message = ( + f"data error - It may have uploaded, go check\n" + f'Step 2 of upload to {self.tracker} failed.\n' + f'Status code: {response.status_code}\n' + f'URL: {response.url}\n' + f"The HTML response has been saved to '{failure_path}' for analysis." + ) + meta['tracker_status'][self.tracker]['status_message'] = status_message + return + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading.' + + meta['tracker_status'][self.tracker]['status_message'] = status_message + + def language_map(self): + all_lang_map = { + ('Abkhazian', 'abk', 'ab'): '1', + ('Afar', 'aar', 'aa'): '2', + ('Afrikaans', 'afr', 'af'): '3', + ('Akan', 'aka', 'ak'): '4', + ('Albanian', 'sqi', 'sq'): '5', + ('Amharic', 'amh', 'am'): '6', + ('Arabic', 'ara', 'ar'): '7', + ('Aragonese', 'arg', 'an'): '8', + ('Armenian', 'hye', 'hy'): '9', + ('Assamese', 'asm', 'as'): '10', + ('Avaric', 'ava', 'av'): '11', + ('Avestan', 'ave', 'ae'): '12', + ('Aymara', 'aym', 'ay'): '13', + ('Azerbaijani', 'aze', 'az'): '14', + ('Bambara', 'bam', 'bm'): '15', + ('Bashkir', 'bak', 'ba'): '16', + ('Basque', 'eus', 'eu'): '17', + ('Belarusian', 'bel', 'be'): '18', + ('Bengali', 'ben', 'bn'): '19', + ('Bihari languages', 'bih', 'bh'): '20', + ('Bislama', 'bis', 'bi'): '21', + ('Bokmål, Norwegian', 'nob', 'nb'): '22', + ('Bosnian', 'bos', 'bs'): '23', + ('Breton', 'bre', 'br'): '24', + ('Bulgarian', 'bul', 'bg'): '25', + ('Burmese', 'mya', 'my'): '26', + ('Cantonese', 'yue', 'zh'): '27', + ('Catalan', 'cat', 'ca'): '28', + ('Central Khmer', 'khm', 'km'): '29', + ('Chamorro', 'cha', 'ch'): '30', + ('Chechen', 'che', 'ce'): '31', + ('Chichewa', 'nya', 'ny'): '32', + ('Chinese', 'zho', 'zh'): '33', + ('Church Slavic', 'chu', 'cu'): '34', + ('Chuvash', 'chv', 'cv'): '35', + ('Cornish', 'cor', 'kw'): '36', + ('Corsican', 'cos', 'co'): '37', + ('Cree', 'cre', 'cr'): '38', + ('Croatian', 'hrv', 'hr'): '39', + ('Czech', 'ces', 'cs'): '40', + ('Danish', 'dan', 'da'): '41', + ('Dhivehi', 'div', 'dv'): '42', + ('Dutch', 'nld', 'nl'): '43', + ('Dzongkha', 'dzo', 'dz'): '44', + ('English', 'eng', 'en'): '45', + ('Esperanto', 'epo', 'eo'): '46', + ('Estonian', 'est', 'et'): '47', + ('Ewe', 'ewe', 'ee'): '48', + ('Faroese', 'fao', 'fo'): '49', + ('Fijian', 'fij', 'fj'): '50', + ('Finnish', 'fin', 'fi'): '51', + ('French', 'fra', 'fr'): '52', + ('Fulah', 'ful', 'ff'): '53', + ('Gaelic', 'gla', 'gd'): '54', + ('Galician', 'glg', 'gl'): '55', + ('Ganda', 'lug', 'lg'): '56', + ('Georgian', 'kat', 'ka'): '57', + ('German', 'deu', 'de'): '58', + ('Greek', 'ell', 'el'): '59', + ('Guarani', 'grn', 'gn'): '60', + ('Gujarati', 'guj', 'gu'): '61', + ('Haitian', 'hat', 'ht'): '62', + ('Hausa', 'hau', 'ha'): '63', + ('Hebrew', 'heb', 'he'): '64', + ('Herero', 'her', 'hz'): '65', + ('Hindi', 'hin', 'hi'): '66', + ('Hiri Motu', 'hmo', 'ho'): '67', + ('Hungarian', 'hun', 'hu'): '68', + ('Icelandic', 'isl', 'is'): '69', + ('Ido', 'ido', 'io'): '70', + ('Igbo', 'ibo', 'ig'): '71', + ('Indonesian', 'ind', 'id'): '72', + ('Interlingua', 'ina', 'ia'): '73', + ('Interlingue', 'ile', 'ie'): '74', + ('Inuktitut', 'iku', 'iu'): '75', + ('Inupiaq', 'ipk', 'ik'): '76', + ('Irish', 'gle', 'ga'): '77', + ('Italian', 'ita', 'it'): '78', + ('Japanese', 'jpn', 'ja'): '79', + ('Javanese', 'jav', 'jv'): '80', + ('Kalaallisut', 'kal', 'kl'): '81', + ('Kannada', 'kan', 'kn'): '82', + ('Kanuri', 'kau', 'kr'): '83', + ('Kashmiri', 'kas', 'ks'): '84', + ('Kazakh', 'kaz', 'kk'): '85', + ('Kikuyu', 'kik', 'ki'): '86', + ('Kinyarwanda', 'kin', 'rw'): '87', + ('Kirghiz', 'kir', 'ky'): '88', + ('Komi', 'kom', 'kv'): '89', + ('Kongo', 'kon', 'kg'): '90', + ('Korean', 'kor', 'ko'): '91', + ('Kuanyama', 'kua', 'kj'): '92', + ('Kurdish', 'kur', 'ku'): '93', + ('Lao', 'lao', 'lo'): '94', + ('Latin', 'lat', 'la'): '95', + ('Latvian', 'lav', 'lv'): '96', + ('Limburgan', 'lim', 'li'): '97', + ('Lingala', 'lin', 'ln'): '98', + ('Lithuanian', 'lit', 'lt'): '99', + ('Luba-Katanga', 'lub', 'lu'): '100', + ('Luxembourgish', 'ltz', 'lb'): '101', + ('Macedonian', 'mkd', 'mk'): '102', + ('Malagasy', 'mlg', 'mg'): '103', + ('Malay', 'msa', 'ms'): '104', + ('Malayalam', 'mal', 'ml'): '105', + ('Maltese', 'mlt', 'mt'): '106', + ('Mandarin', 'cmn', 'cmn'): '107', + ('Manx', 'glv', 'gv'): '108', + ('Maori', 'mri', 'mi'): '109', + ('Marathi', 'mar', 'mr'): '110', + ('Marshallese', 'mah', 'mh'): '111', + ('Mongolian', 'mon', 'mn'): '112', + ('Nauru', 'nau', 'na'): '113', + ('Navajo', 'nav', 'nv'): '114', + ('Ndebele, North', 'nde', 'nd'): '115', + ('Ndebele, South', 'nbl', 'nr'): '116', + ('Ndonga', 'ndo', 'ng'): '117', + ('Nepali', 'nep', 'ne'): '118', + ('Northern Sami', 'sme', 'se'): '119', + ('Norwegian', 'nor', 'no'): '120', + ('Norwegian Nynorsk', 'nno', 'nn'): '121', + ('Occitan (post 1500)', 'oci', 'oc'): '122', + ('Ojibwa', 'oji', 'oj'): '123', + ('Oriya', 'ori', 'or'): '124', + ('Oromo', 'orm', 'om'): '125', + ('Ossetian', 'oss', 'os'): '126', + ('Pali', 'pli', 'pi'): '127', + ('Panjabi', 'pan', 'pa'): '128', + ('Persian', 'fas', 'fa'): '129', + ('Polish', 'pol', 'pl'): '130', + ('Portuguese', 'por', 'pt'): '131', + ('Pushto', 'pus', 'ps'): '132', + ('Quechua', 'que', 'qu'): '133', + ('Romanian', 'ron', 'ro'): '134', + ('Romansh', 'roh', 'rm'): '135', + ('Rundi', 'run', 'rn'): '136', + ('Russian', 'rus', 'ru'): '137', + ('Samoan', 'smo', 'sm'): '138', + ('Sango', 'sag', 'sg'): '139', + ('Sanskrit', 'san', 'sa'): '140', + ('Sardinian', 'srd', 'sc'): '141', + ('Serbian', 'srp', 'sr'): '142', + ('Shona', 'sna', 'sn'): '143', + ('Sichuan Yi', 'iii', 'ii'): '144', + ('Sindhi', 'snd', 'sd'): '145', + ('Sinhala', 'sin', 'si'): '146', + ('Slovak', 'slk', 'sk'): '147', + ('Slovenian', 'slv', 'sl'): '148', + ('Somali', 'som', 'so'): '149', + ('Sotho, Southern', 'sot', 'st'): '150', + ('Spanish', 'spa', 'es'): '151', + ('Sundanese', 'sun', 'su'): '152', + ('Swahili', 'swa', 'sw'): '153', + ('Swati', 'ssw', 'ss'): '154', + ('Swedish', 'swe', 'sv'): '155', + ('Tagalog', 'tgl', 'tl'): '156', + ('Tahitian', 'tah', 'ty'): '157', + ('Tajik', 'tgk', 'tg'): '158', + ('Tamil', 'tam', 'ta'): '159', + ('Tatar', 'tat', 'tt'): '160', + ('Telugu', 'tel', 'te'): '161', + ('Thai', 'tha', 'th'): '162', + ('Tibetan', 'bod', 'bo'): '163', + ('Tigrinya', 'tir', 'ti'): '164', + ('Tongan', 'ton', 'to'): '165', + ('Tsonga', 'tso', 'ts'): '166', + ('Tswana', 'tsn', 'tn'): '167', + ('Turkish', 'tur', 'tr'): '168', + ('Turkmen', 'tuk', 'tk'): '169', + ('Twi', 'twi', 'tw'): '170', + ('Uighur', 'uig', 'ug'): '171', + ('Ukrainian', 'ukr', 'uk'): '172', + ('Urdu', 'urd', 'ur'): '173', + ('Uzbek', 'uzb', 'uz'): '174', + ('Venda', 'ven', 've'): '175', + ('Vietnamese', 'vie', 'vi'): '176', + ('Volapük', 'vol', 'vo'): '177', + ('Walloon', 'wln', 'wa'): '178', + ('Welsh', 'cym', 'cy'): '179', + ('Western Frisian', 'fry', 'fy'): '180', + ('Wolof', 'wol', 'wo'): '181', + ('Xhosa', 'xho', 'xh'): '182', + ('Yiddish', 'yid', 'yi'): '183', + ('Yoruba', 'yor', 'yo'): '184', + ('Zhuang', 'zha', 'za'): '185', + ('Zulu', 'zul', 'zu'): '186', + } + + if self.tracker == 'PHD': + all_lang_map.update({ + ('Portuguese (BR)', 'por', 'pt-br'): '187', + ('Filipino', 'fil', 'fil'): '189', + ('Mooré', 'mos', 'mos'): '188', + }) + + if self.tracker == 'AZ': + all_lang_map.update({ + ('Portuguese (BR)', 'por', 'pt-br'): '189', + ('Filipino', 'fil', 'fil'): '188', + ('Mooré', 'mos', 'mos'): '187', + }) + + if self.tracker == 'CZ': + all_lang_map.update({ + ('Portuguese (BR)', 'por', 'pt-br'): '187', + ('Mooré', 'mos', 'mos'): '188', + ('Filipino', 'fil', 'fil'): '189', + ('Bissa', 'bib', 'bib'): '190', + ('Romani', 'rom', 'rom'): '191', + }) + + self.lang_map = {} + for key_tuple, lang_id in all_lang_map.items(): + for alias in key_tuple: + if alias: + self.lang_map[alias.lower()] = lang_id diff --git a/src/trackers/AZ.py b/src/trackers/AZ.py new file mode 100644 index 000000000..5a37f3a61 --- /dev/null +++ b/src/trackers/AZ.py @@ -0,0 +1,179 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.AVISTAZ_NETWORK import AZTrackerBase + + +class AZ(AZTrackerBase): + def __init__(self, config): + super().__init__(config, tracker_name='AZ') + self.config = config + self.common = COMMON(config) + self.tracker = 'AZ' + self.source_flag = 'AvistaZ' + self.banned_groups = [''] + self.base_url = 'https://avistaz.to' + self.torrent_url = f'{self.base_url}/torrent/' + + async def rules(self, meta): + warnings = [] + + is_disc = False + if meta.get('is_disc', ''): + is_disc = True + + video_codec = meta.get('video_codec', '') + if video_codec: + video_codec = video_codec.strip().lower() + + video_encode = meta.get('video_encode', '') + if video_encode: + video_encode = video_encode.strip().lower() + + type = meta.get('type', '') + if type: + type = type.strip().lower() + + source = meta.get('source', '') + if source: + source = source.strip().lower() + + image_links = [img.get('raw_url') for img in meta.get('image_list', []) if img.get('raw_url')] + if len(image_links) < 3: + warnings.append(f'{self.tracker}: At least 3 screenshots are required to upload.') + + # This also checks the rule 'FANRES content is not allowed' + if meta['category'] not in ('MOVIE', 'TV'): + warnings.append( + 'The only allowed content to be uploaded are Movies and TV Shows.\n' + 'Anything else, like games, music, software and porn is not allowed!' + ) + + if meta.get('anime', False): + warnings.append("Upload Anime content to our sister site AnimeTorrents.me instead. If it's on AniDB, it's an anime.") + + # https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes + + africa = [ + 'AO', 'BF', 'BI', 'BJ', 'BW', 'CD', 'CF', 'CG', 'CI', 'CM', 'CV', 'DJ', 'DZ', 'EG', 'EH', + 'ER', 'ET', 'GA', 'GH', 'GM', 'GN', 'GQ', 'GW', 'IO', 'KE', 'KM', 'LR', 'LS', 'LY', 'MA', + 'MG', 'ML', 'MR', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'RE', 'RW', 'SC', 'SD', 'SH', 'SL', + 'SN', 'SO', 'SS', 'ST', 'SZ', 'TD', 'TF', 'TG', 'TN', 'TZ', 'UG', 'YT', 'ZA', 'ZM', 'ZW' + ] + america = [ + 'AG', 'AI', 'AR', 'AW', 'BB', 'BL', 'BM', 'BO', 'BQ', 'BR', 'BS', 'BV', 'BZ', 'CA', 'CL', + 'CO', 'CR', 'CU', 'CW', 'DM', 'DO', 'EC', 'FK', 'GD', 'GF', 'GL', 'GP', 'GS', 'GT', 'GY', + 'HN', 'HT', 'JM', 'KN', 'KY', 'LC', 'MF', 'MQ', 'MS', 'MX', 'NI', 'PA', 'PE', 'PM', 'PR', + 'PY', 'SR', 'SV', 'SX', 'TC', 'TT', 'US', 'UY', 'VC', 'VE', 'VG', 'VI' + ] + asia = [ + 'AE', 'AF', 'AM', 'AZ', 'BD', 'BH', 'BN', 'BT', 'CN', 'CY', 'GE', 'HK', 'ID', 'IL', 'IN', + 'IQ', 'IR', 'JO', 'JP', 'KG', 'KH', 'KP', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LK', 'MM', 'MN', + 'MO', 'MV', 'MY', 'NP', 'OM', 'PH', 'PK', 'PS', 'QA', 'SA', 'SG', 'SY', 'TH', 'TJ', 'TL', + 'TM', 'TR', 'TW', 'UZ', 'VN', 'YE' + ] + europe = [ + 'AD', 'AL', 'AT', 'AX', 'BA', 'BE', 'BG', 'BY', 'CH', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', + 'FO', 'FR', 'GB', 'GG', 'GI', 'GR', 'HR', 'HU', 'IE', 'IM', 'IS', 'IT', 'JE', 'LI', 'LT', + 'LU', 'LV', 'MC', 'MD', 'ME', 'MK', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'RS', 'RU', 'SE', + 'SI', 'SJ', 'SK', 'SM', 'SU', 'UA', 'VA', 'XC' + ] + oceania = [ + 'AS', 'AU', 'CC', 'CK', 'CX', 'FJ', 'FM', 'GU', 'HM', 'KI', 'MH', 'MP', 'NC', 'NF', 'NR', + 'NU', 'NZ', 'PF', 'PG', 'PN', 'PW', 'SB', 'TK', 'TO', 'TV', 'UM', 'VU', 'WF', 'WS' + ] + + az_allowed_countries = [ + 'BD', 'BN', 'BT', 'CN', 'HK', 'ID', 'IN', 'JP', 'KH', 'KP', 'KR', 'LA', 'LK', + 'MM', 'MN', 'MO', 'MY', 'NP', 'PH', 'PK', 'SG', 'TH', 'TL', 'TW', 'VN' + ] + + phd_countries = [ + 'AG', 'AI', 'AU', 'BB', 'BM', 'BS', 'BZ', 'CA', 'CW', 'DM', 'GB', 'GD', 'IE', + 'JM', 'KN', 'KY', 'LC', 'MS', 'NZ', 'PR', 'TC', 'TT', 'US', 'VC', 'VG', 'VI', + ] + + all_countries = africa + america + asia + europe + oceania + cinemaz_countries = list(set(all_countries) - set(phd_countries) - set(az_allowed_countries)) + + origin_countries_codes = meta.get('origin_country', []) + + if any(code in phd_countries for code in origin_countries_codes): + warnings.append( + 'DO NOT upload content from major English speaking countries ' + '(USA, UK, Canada, etc). Upload this to our sister site PrivateHD.to instead.' + ) + + elif any(code in cinemaz_countries for code in origin_countries_codes): + warnings.append( + 'DO NOT upload non-allowed Asian or Western content. ' + 'Upload this content to our sister site CinemaZ.to instead.' + ) + + if not is_disc: + if meta.get('container') not in ['mkv', 'mp4', 'avi']: + warnings.append('Allowed containers: MKV, MP4, AVI.') + + if not is_disc: + if video_codec not in ('avc', 'h.264', 'h.265', 'x264', 'x265', 'hevc', 'divx', 'xvid'): + warnings.append( + f'Video codec not allowed in your upload: {video_codec}.\n' + 'Allowed: H264/x264/AVC, H265/x265/HEVC, DivX/Xvid\n' + 'Exceptions:\n' + ' MPEG2 for Full DVD discs and HDTV recordings\n' + " VC-1/MPEG2 for Bluray only if that's what is on the disc" + ) + + if is_disc: + pass + else: + allowed_keywords = [ + 'AC3', 'Audio Layer III', 'MP3', 'Dolby Digital', 'Dolby TrueHD', + 'DTS', 'DTS-HD', 'FLAC', 'AAC', 'Dolby' + ] + + is_untouched_opus = False + audio_field = meta.get('audio', '') + if isinstance(audio_field, str) and 'opus' in audio_field.lower() and bool(meta.get('untouched', False)): + is_untouched_opus = True + + audio_tracks = [] + media_tracks = meta.get('mediainfo', {}).get('media', {}).get('track', []) + for track in media_tracks: + if track.get('@type') == 'Audio': + codec_info = track.get('Format_Commercial_IfAny') or track.get('Format') + codec = codec_info if isinstance(codec_info, str) else '' + audio_tracks.append({ + 'codec': codec, + 'language': track.get('Language', '') + }) + + invalid_codecs = [] + for track in audio_tracks: + codec = track['codec'] + if not codec: + continue + + if 'opus' in codec.lower(): + if is_untouched_opus: + continue + else: + invalid_codecs.append(codec) + continue + + is_allowed = any(kw.lower() in codec.lower() for kw in allowed_keywords) + if not is_allowed: + invalid_codecs.append(codec) + + if invalid_codecs: + unique_invalid_codecs = sorted(list(set(invalid_codecs))) + warnings.append( + f"Unallowed audio codec(s) detected: {', '.join(unique_invalid_codecs)}\n" + f'Allowed codecs: AC3 (Dolby Digital), Dolby TrueHD, DTS, DTS-HD (MA), FLAC, AAC, MP3, etc.\n' + f'Exceptions: Untouched Opus from source; Uncompressed codecs from Blu-ray discs (PCM, LPCM).' + ) + + if warnings: + all_warnings = '\n\n'.join(filter(None, warnings)) + return all_warnings + + return diff --git a/src/trackers/BHD.py b/src/trackers/BHD.py index d6ce9bca1..4c4b1e8d8 100644 --- a/src/trackers/BHD.py +++ b/src/trackers/BHD.py @@ -1,15 +1,16 @@ # -*- coding: utf-8 -*- # import discord import asyncio -import requests -from difflib import SequenceMatcher -import distutils.util -import urllib import os import platform - +import httpx +import re +import cli_ui +import aiofiles from src.trackers.COMMON import COMMON from src.console import console +from src.rehostimages import check_hosts + class BHD(): """ @@ -19,17 +20,31 @@ class BHD(): Set type/category IDs Upload """ + def __init__(self, config): self.config = config self.tracker = 'BHD' self.source_flag = 'BHD' self.upload_url = 'https://beyond-hd.me/api/upload/' - self.signature = f"\n[center][url=https://beyond-hd.me/forums/topic/toolpython-l4gs-upload-assistant.5456]Created by L4G's Upload Assistant[/url][/center]" - self.banned_groups = ['Sicario', 'TOMMY', 'x0r', 'nikt0', 'FGT', 'd3g', 'MeGusta', 'YIFY', 'tigole', 'TEKNO3D', 'C4K', 'RARBG', '4K4U', 'EASports', 'ReaLHD'] + self.torrent_url = 'https://beyond-hd.me/details/' + self.requests_url = f"https://beyond-hd.me/api/requests/{self.config['TRACKERS']['BHD']['api_key'].strip()}" + self.signature = "\n[center][url=https://github.com/Audionut/Upload-Assistant]Created by Upload Assistant[/url][/center]" + self.banned_groups = ['Sicario', 'TOMMY', 'x0r', 'nikt0', 'FGT', 'd3g', 'MeGusta', 'YIFY', 'tigole', 'TEKNO3D', 'C4K', 'RARBG', '4K4U', 'EASports', 'ReaLHD', 'Telly', 'AOC', 'WKS', 'SasukeducK'] pass - - async def upload(self, meta): + + async def upload(self, meta, disctype): common = COMMON(config=self.config) + url_host_mapping = { + "ibb.co": "imgbb", + "ptpimg.me": "ptpimg", + "pixhost.to": "pixhost", + "imgbox.com": "imgbox", + "beyondhd.co": "bhd", + "imagebam.com": "bam", + } + + approved_image_hosts = ['ptpimg', 'imgbox', 'imgbb', 'pixhost', 'bhd', 'bam'] + await check_hosts(meta, self.tracker, url_host_mapping=url_host_mapping, img_host_index=1, approved_image_hosts=approved_image_hosts) await common.edit_torrent(meta, self.tracker, self.source_flag) cat_id = await self.get_cat_id(meta['category']) source_id = await self.get_source(meta['source']) @@ -39,37 +54,41 @@ async def upload(self, meta): tags = await self.get_tags(meta) custom, edition = await self.get_edition(meta, tags) bhd_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): anon = 0 else: anon = 1 - - if meta['bdinfo'] != None: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') + + mi_dump = None + if meta['is_disc'] == "BDMV": + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') as f: + mi_dump = await f.read() else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') - - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - torrent_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent" + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') as f: + mi_dump = await f.read() + + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8') as f: + desc = await f.read() + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + async with aiofiles.open(torrent_file_path, 'rb') as f: + torrent_bytes = await f.read() + files = { - 'mediainfo' : mi_dump, - } - if os.path.exists(torrent_file): - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files['file'] = open_torrent.read() - open_torrent.close() - + 'mediainfo': mi_dump, + 'file': ('torrent.torrent', torrent_bytes, 'application/x-bittorrent'), + } + data = { - 'name' : bhd_name, - 'category_id' : cat_id, - 'type' : type_id, + 'name': bhd_name, + 'category_id': cat_id, + 'type': type_id, 'source': source_id, - 'imdb_id' : meta['imdb_id'].replace('tt', ''), - 'tmdb_id' : meta['tmdb'], - 'description' : desc, - 'anon' : anon, - 'sd' : meta.get('sd', 0), - 'live' : draft + 'imdb_id': meta['imdb'], + 'tmdb_id': meta['tmdb'], + 'description': desc, + 'anon': anon, + 'sd': meta.get('sd', 0), + 'live': draft # 'internal' : 0, # 'featured' : 0, # 'free' : 0, @@ -77,74 +96,89 @@ async def upload(self, meta): # 'sticky' : 0, } # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: + if self.config['TRACKERS'][self.tracker].get('internal', False) is True: if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): data['internal'] = 1 - + if meta.get('tv_pack', 0) == 1: data['pack'] = 1 if meta.get('season', None) == "S00": data['special'] = 1 - if meta.get('region', "") != "": + allowed_regions = ['AUS', 'CAN', 'CEE', 'CHN', 'ESP', 'EUR', 'FRA', 'GBR', 'GER', 'HKG', 'ITA', 'JPN', 'KOR', 'NOR', 'NLD', 'RUS', 'TWN', 'USA'] + if meta.get('region', "") in allowed_regions: data['region'] = meta['region'] - if custom == True: + if custom is True: data['custom_edition'] = edition elif edition != "": data['edition'] = edition if len(tags) > 0: data['tags'] = ','.join(tags) headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' + 'User-Agent': f'Upload Assistant/2.3 ({platform.system()} {platform.release()})' } - + url = self.upload_url + self.config['TRACKERS'][self.tracker]['api_key'].strip() - if meta['debug'] == False: - response = requests.post(url=url, files=files, data=data, headers=headers) + details_link = {} + if meta['debug'] is False: try: - response = response.json() - if int(response['status_code']) == 0: - console.print(f"[red]{response['status_message']}") - if response['status_message'].startswith('Invalid imdb_id'): - console.print('[yellow]RETRYING UPLOAD') - data['imdb_id'] = 1 - response = requests.post(url=url, files=files, data=data, headers=headers) - response = response.json() - elif response['satus_message'].startswith('Invalid name value'): - console.print(f"[bold yellow]Submitted Name: {bhd_name}") - console.print(response) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - - - + async with httpx.AsyncClient(timeout=60) as client: + response = await client.post(url=url, files=files, data=data, headers=headers) + response_json = response.json() + if int(response_json['status_code']) == 0: + console.print(f"[red]{response_json['status_message']}") + if response_json['status_message'].startswith('Invalid imdb_id'): + console.print('[yellow]RETRYING UPLOAD') + data['imdb_id'] = 1 + response = await client.post(url=url, files=files, data=data, headers=headers) + response_json = response.json() + elif response_json['status_message'].startswith('Invalid name value'): + console.print(f"[bold yellow]Submitted Name: {bhd_name}") + if 'status_message' in response_json: + match = re.search(r"https://beyond-hd\.me/torrent/download/.*\.(\d+)\.", response_json['status_message']) + if match: + torrent_id = match.group(1) + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + details_link = f"https://beyond-hd.me/details/{torrent_id}" + else: + console.print("[yellow]No valid details link found in status_message.") + meta['tracker_status'][self.tracker]['status_message'] = response.json() + except Exception as e: + meta['tracker_status'][self.tracker]['status_message'] = f"Error: {e}" + return + else: + console.print("[cyan]BHD Request Data:") + console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." + if details_link: + try: + await common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.config['TRACKERS'][self.tracker].get('announce_url'), details_link) + except Exception as e: + console.print(f"Error while editing the torrent file: {e}") async def get_cat_id(self, category_name): category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '1') + 'MOVIE': '1', + 'TV': '2', + }.get(category_name, '1') return category_id async def get_source(self, source): sources = { - "Blu-ray" : "Blu-ray", - "BluRay" : "Blu-ray", - "HDDVD" : "HD-DVD", - "HD DVD" : "HD-DVD", - "Web" : "WEB", - "HDTV" : "HDTV", - "UHDTV" : "HDTV", - "NTSC" : "DVD", "NTSC DVD" : "DVD", - "PAL" : "DVD", "PAL DVD": "DVD", + "Blu-ray": "Blu-ray", + "BluRay": "Blu-ray", + "HDDVD": "HD-DVD", + "HD DVD": "HD-DVD", + "WEB": "WEB", + "Web": "WEB", + "HDTV": "HDTV", + "UHDTV": "HDTV", + "NTSC": "DVD", "NTSC DVD": "DVD", + "PAL": "DVD", "PAL DVD": "DVD", } - + source_id = sources.get(source) return source_id @@ -166,7 +200,7 @@ async def get_type(self, meta): if "DVD5" in meta['dvd_size']: type_id = "DVD 5" elif "DVD9" in meta['dvd_size']: - type_id = "DVD 9" + type_id = "DVD 9" else: if meta['type'] == "REMUX": if meta['source'] == "BluRay": @@ -185,91 +219,189 @@ async def get_type(self, meta): type_id = "Other" return type_id - - async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as desc: + desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + base_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + async with aiofiles.open(base_path, 'r', encoding='utf-8') as f: + base = await f.read() + async with aiofiles.open(desc_path, 'w', encoding='utf-8') as desc: if meta.get('discs', []) != []: discs = meta['discs'] if discs[0]['type'] == "DVD": - desc.write(f"[spoiler=VOB MediaInfo][code]{discs[0]['vob_mi']}[/code][/spoiler]") - desc.write("\n") + await desc.write(f"[spoiler=VOB MediaInfo][code]{discs[0]['vob_mi']}[/code][/spoiler]") + await desc.write("\n") if len(discs) >= 2: for each in discs[1:]: if each['type'] == "BDMV": - desc.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]") - desc.write("\n") + await desc.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]") + await desc.write("\n") elif each['type'] == "DVD": - desc.write(f"{each['name']}:\n") - desc.write(f"[spoiler={os.path.basename(each['vob'])}][code][{each['vob_mi']}[/code][/spoiler] [spoiler={os.path.basename(each['ifo'])}][code][{each['ifo_mi']}[/code][/spoiler]") - desc.write("\n") + await desc.write(f"{each['name']}:\n") + await desc.write(f"[spoiler={os.path.basename(each['vob'])}][code][{each['vob_mi']}[/code][/spoiler] [spoiler={os.path.basename(each['ifo'])}][code][{each['ifo_mi']}[/code][/spoiler]") + await desc.write("\n") elif each['type'] == "HDDVD": - desc.write(f"{each['name']}:\n") - desc.write(f"[spoiler={os.path.basename(each['largest_evo'])}][code][{each['evo_mi']}[/code][/spoiler]\n") - desc.write("\n") - desc.write(base.replace("[img]", "[img width=300]")) - images = meta['image_list'] - if len(images) > 0: - desc.write("[center]") + await desc.write(f"{each['name']}:\n") + await desc.write(f"[spoiler={os.path.basename(each['largest_evo'])}][code][{each['evo_mi']}[/code][/spoiler]\n") + await desc.write("\n") + await desc.write(base.replace("[img]", "[img width=300]")) + try: + # If screensPerRow is set, use that to determine how many screenshots should be on each row. Otherwise, use 2 as default + screensPerRow = int(self.config['DEFAULT'].get('screens_per_row', 2)) + except Exception: + screensPerRow = 2 + if meta.get('comparison') and meta.get('comparison_groups'): + await desc.write("[center]") + comparison_groups = meta.get('comparison_groups', {}) + sorted_group_indices = sorted(comparison_groups.keys(), key=lambda x: int(x)) + + comp_sources = [] + for group_idx in sorted_group_indices: + group_data = comparison_groups[group_idx] + group_name = group_data.get('name', f'Group {group_idx}') + comp_sources.append(group_name) + + sources_string = ", ".join(comp_sources) + await desc.write(f"[comparison={sources_string}]\n") + + images_per_group = min([ + len(comparison_groups[idx].get('urls', [])) + for idx in sorted_group_indices + ]) + + for img_idx in range(images_per_group): + for group_idx in sorted_group_indices: + group_data = comparison_groups[group_idx] + urls = group_data.get('urls', []) + if img_idx < len(urls): + img_url = urls[img_idx].get('raw_url', '') + if img_url: + await desc.write(f"{img_url}\n") + + await desc.write("[/comparison][/center]\n\n") + try: + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + tonemapped_header = self.config['DEFAULT'].get('tonemapped_header') + await desc.write(tonemapped_header) + await desc.write("\n\n") + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + if f'{self.tracker}_images_key' in meta: + images = meta[f'{self.tracker}_images_key'] + else: + images = meta['image_list'] + if len(images) > 0: + await desc.write("[align=center]") for each in range(len(images[:int(meta['screens'])])): web_url = images[each]['web_url'] img_url = images[each]['img_url'] - desc.write(f"[url={web_url}][img width=350]{img_url}[/img][/url]") - desc.write("[/center]") - desc.write(self.signature) - desc.close() + if (each == len(images) - 1): + await desc.write(f"[url={web_url}][img width=350]{img_url}[/img][/url]") + elif (each + 1) % screensPerRow == 0: + await desc.write(f"[url={web_url}][img width=350]{img_url}[/img][/url]\n") + await desc.write("\n") + elif (each + 1) % 2 == 0: + await desc.write(f"[url={web_url}][img width=350]{img_url}[/img][/url]\n") + await desc.write("\n") + else: + await desc.write(f"[url={web_url}][img width=350]{img_url}[/img][/url] ") + await desc.write("[/align]") + await desc.write(self.signature) + await desc.close() return - + async def search_existing(self, meta, disctype): + bhd_name = await self.edit_name(meta) + if any(phrase in bhd_name.lower() for phrase in ( + "-framestor", "-bhdstudio", "-bmf", "-decibel", "-d-zone", "-hifi", + "-ncmt", "-tdd", "-flux", "-crfw", "-sonny", "-zr-", "-mkvultra", + "-rpg", "-w4nk3r", "-irobot", "-beyondhd" + )): + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print("[bold red]This is an internal BHD release, skipping upload[/bold red]") + if cli_ui.ask_yes_no("Do you want to upload anyway?", default=False): + pass + else: + meta['skipping'] = "BHD" + return [] + else: + meta['skipping'] = "BHD" + return [] + if meta['sd'] and not (meta['is_disc'] or "REMUX" in meta['type'] or "WEBDL" in meta['type']): + if not meta['unattended']: + console.print("[bold red]Modified SD content not allowed at BHD[/bold red]") + meta['skipping'] = "BHD" + return [] + if meta['bloated'] is True: + console.print("[bold red]Non-English dub not allowed at BHD[/bold red]") + meta['skipping'] = "BHD" + return [] - async def search_existing(self, meta): dupes = [] - console.print("[yellow]Searching for existing torrents on site...") category = meta['category'] + tmdbID = "movie" if category == 'MOVIE' else "tv" if category == 'MOVIE': category = "Movies" + elif category == "TV": + category = "TV" + if meta['is_disc'] == "DVD": + type = None + else: + type = await self.get_type(meta) data = { - 'tmdb_id' : meta['tmdb'], - 'categories' : category, - 'types' : await self.get_type(meta), + 'action': 'search', + 'tmdb_id': f"{tmdbID}/{meta['tmdb']}", + 'types': type, + 'categories': category } - # Search all releases if SD if meta['sd'] == 1: data['categories'] = None data['types'] = None if meta['category'] == 'TV': - if meta.get('tv_pack', 0) == 1: - data['pack'] = 1 - data['search'] = f"{meta.get('season', '')}{meta.get('episode', '')}" - url = f"https://beyond-hd.me/api/torrents/{self.config['TRACKERS']['BHD']['api_key'].strip()}?action=search" + data['search'] = f"{meta.get('season', '')}" + + url = f"https://beyond-hd.me/api/torrents/{self.config['TRACKERS']['BHD']['api_key'].strip()}" try: - response = requests.post(url=url, data=data) - response = response.json() - if response.get('status_code') == 1: - for each in response['results']: - result = each['name'] - difference = SequenceMatcher(None, meta['clean_name'].replace('DD+', 'DDP'), result).ratio() - if difference >= 0.05: - dupes.append(result) - else: - console.print(f"[yellow]{response.get('status_message')}") - await asyncio.sleep(5) - except: - console.print('[bold red]Unable to search for existing torrents on site. Most likely the site is down.') + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(url, params=data) + if response.status_code == 200: + data = response.json() + if data.get('status_code') == 1: + for each in data['results']: + result = { + 'name': each['name'], + 'link': each['url'], + 'size': each['size'], + } + dupes.append(result) + else: + console.print(f"[bold red]BHD failed to search torrents. API Error: {data.get('message', 'Unknown Error')}") + else: + console.print(f"[bold red]BHD HTTP request failed. Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]BHD request timed out after 5 seconds") + except httpx.RequestError as e: + console.print(f"[bold red]BHD unable to search for existing torrents: {e}") + except Exception as e: + console.print(f"[bold red]BHD unexpected error: {e}") await asyncio.sleep(5) return dupes - async def get_live(self, meta): - draft = self.config['TRACKERS'][self.tracker]['draft_default'].strip() - draft = bool(distutils.util.strtobool(str(draft))) #0 for send to draft, 1 for live - if draft: - draft_int = 0 + def _is_true(self, value): + """ + Converts a value to a boolean. Returns True for "true", "1", "yes" (case-insensitive), and False otherwise. + """ + return str(value).strip().lower() in {"true", "1", "yes"} + + async def get_live(self, meta): + draft_value = self.config['TRACKERS'][self.tracker].get('draft_default', False) + if isinstance(draft_value, bool): + draft_bool = draft_value else: - draft_int = 1 - if meta['draft']: - draft_int = 0 + draft_bool = self._is_true(str(draft_value).strip()) + + draft_int = 0 if draft_bool or meta.get('draft') else 1 + return draft_int async def get_edition(self, meta, tags): @@ -284,7 +416,7 @@ async def get_edition(self, meta, tags): elif edition == "": edition = "" else: - custom = True + custom = True return custom, edition async def get_tags(self, meta): @@ -301,13 +433,13 @@ async def get_tags(self, meta): tags.append('EnglishDub') if "Open Matte" in meta.get('edition', ""): tags.append("OpenMatte") - if meta.get('scene', False) == True: + if meta.get('scene', False) is True: tags.append("Scene") - if meta.get('personalrelease', False) == True: + if meta.get('personalrelease', False) is True: tags.append('Personal') if "hybrid" in meta.get('edition', "").lower(): tags.append('Hybrid') - if meta.get('has_commentary', False) == True: + if meta.get('has_commentary', False) is True: tags.append('Commentary') if "DV" in meta.get('hdr', ''): tags.append('DV') @@ -327,8 +459,4 @@ async def edit_name(self, meta): audio = ' '.join(audio.split()) name = name.replace(audio, f"{meta.get('video_codec')} {audio}") name = name.replace("DD+", "DDP") - # if meta['type'] == 'WEBDL' and meta.get('has_encode_settings', False) == True: - # name = name.replace('H.264', 'x264') - if meta['category'] == "TV" and meta.get('tv_pack', 0) == 0 and meta.get('episode_title_storage', '').strip() != '' and meta['episode'].strip() != '': - name = name.replace(meta['episode'], f"{meta['episode']} {meta['episode_title_storage']}", 1) - return name \ No newline at end of file + return name diff --git a/src/trackers/BHDTV.py b/src/trackers/BHDTV.py index 97d0e1c8e..ffa3abed3 100644 --- a/src/trackers/BHDTV.py +++ b/src/trackers/BHDTV.py @@ -1,10 +1,7 @@ # -*- coding: utf-8 -*- # import discord -import asyncio -from torf import Torrent import requests from src.console import console -import distutils.util from pprint import pprint import os import traceback @@ -12,8 +9,6 @@ from pymediainfo import MediaInfo -# from pprint import pprint - class BHDTV(): """ Edit for Tracker: @@ -27,14 +22,14 @@ def __init__(self, config): self.config = config self.tracker = 'BHDTV' self.source_flag = 'BIT-HDTV' - #search not implemented - #self.search_url = 'https://api.bit-hdtv.com/torrent/search/advanced' + # search not implemented + # self.search_url = 'https://api.bit-hdtv.com/torrent/search/advanced' self.upload_url = 'https://www.bit-hdtv.com/takeupload.php' - #self.forum_link = 'https://www.bit-hdtv.com/rules.php' + # self.forum_link = 'https://www.bit-hdtv.com/rules.php' self.banned_groups = [] pass - async def upload(self, meta): + async def upload(self, meta, disctype): common = COMMON(config=self.config) await common.edit_torrent(meta, self.tracker, self.source_flag) await self.edit_desc(meta) @@ -48,25 +43,22 @@ async def upload(self, meta): # must be TV pack sub_cat_id = await self.get_type_tv_pack_id(meta['type']) - - resolution_id = await self.get_res_id(meta['resolution']) # region_id = await common.unit3d_region_ids(meta.get('region')) # distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - if meta['anon'] == 0 and bool( - distutils.util.strtobool(self.config['TRACKERS'][self.tracker].get('anon', "False"))) == False: + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): anon = 0 else: - anon = 1 + anon = 1 # noqa F841 - if meta['bdinfo'] != None: + if meta['bdinfo'] is not None: mi_dump = None bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() else: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8').read() bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') + desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8').read() + open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent", 'rb') files = {'file': open_torrent} if meta['is_disc'] != 'BDMV': @@ -80,33 +72,33 @@ async def upload(self, meta): data = { 'api_key': self.config['TRACKERS'][self.tracker]['api_key'].strip(), 'name': meta['name'].replace(' ', '.').replace(':.', '.').replace(':', '.').replace('DD+', 'DDP'), - 'mediainfo': mi_dump if bd_dump == None else bd_dump, + 'mediainfo': mi_dump if bd_dump is None else bd_dump, 'cat': cat_id, 'subcat': sub_cat_id, 'resolution': resolution_id, - #'anon': anon, + # 'anon': anon, # admins asked to remove short description. 'sdescr': " ", - 'descr': media_info if bd_dump == None else "Disc so Check Mediainfo dump ", + 'descr': media_info if bd_dump is None else "Disc so Check Mediainfo dump ", 'screen': desc, 'url': f"https://www.tvmaze.com/shows/{meta['tvmaze_id']}" if meta['category'] == 'TV' else f"https://www.imdb.com/title/tt{meta['imdb_id']}", 'format': 'json' } - - if meta['debug'] == False: + if meta['debug'] is False: response = requests.post(url=self.upload_url, data=data, files=files) try: # pprint(data) - console.print(response.json()) - except: - console.print(f"[cyan]It may have uploaded, go check") + meta['tracker_status'][self.tracker]['status_message'] = response.json() + except Exception: + console.print("[cyan]It may have uploaded, go check") # cprint(f"Request Data:", 'cyan') pprint(data) console.print(traceback.print_exc()) else: - console.print(f"[cyan]Request Data:") + console.print("[cyan]Request Data:") pprint(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." # # adding my anounce url to torrent. if 'view' in response.json()['data']: await common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.config['TRACKERS']['BHDTV'].get('my_announce_url'), response.json()['data']['view']) @@ -116,7 +108,6 @@ async def upload(self, meta): "Torrent Did not upload") open_torrent.close() - async def get_cat_id(self, meta): category_id = '0' if meta['category'] == 'MOVIE': @@ -128,17 +119,16 @@ async def get_cat_id(self, meta): category_id = '10' return category_id - async def get_type_movie_id(self, meta): type_id = '0' - test = meta['type'] + test = meta['type'] # noqa F841 if meta['type'] == 'DISC': if meta['3D']: type_id = '46' else: type_id = '2' elif meta['type'] == 'REMUX': - if str(meta['name']).__contains__('265') : + if str(meta['name']).__contains__('265'): type_id = '48' elif meta['3D']: type_id = '45' @@ -147,58 +137,55 @@ async def get_type_movie_id(self, meta): elif meta['type'] == 'HDTV': type_id = '6' elif meta['type'] == 'ENCODE': - if str(meta['name']).__contains__('265') : + if str(meta['name']).__contains__('265'): type_id = '43' elif meta['3D']: type_id = '44' else: type_id = '1' elif meta['type'] == 'WEBDL' or meta['type'] == 'WEBRIP': - type_id = '5' + type_id = '5' return type_id - async def get_type_tv_id(self, type): type_id = { 'HDTV': '7', 'WEBDL': '8', 'WEBRIP': '8', - #'WEBRIP': '55', - #'SD': '59', + # 'WEBRIP': '55', + # 'SD': '59', 'ENCODE': '10', 'REMUX': '11', 'DISC': '12', }.get(type, '0') return type_id - async def get_type_tv_pack_id(self, type): type_id = { 'HDTV': '13', 'WEBDL': '14', 'WEBRIP': '8', - #'WEBRIP': '55', - #'SD': '59', + # 'WEBRIP': '55', + # 'SD': '59', 'ENCODE': '16', 'REMUX': '17', 'DISC': '18', }.get(type, '0') return type_id - async def get_res_id(self, resolution): resolution_id = { '2160p': '4', '1080p': '3', - '1080i':'2', + '1080i': '2', '720p': '1' - }.get(resolution, '10') + }.get(resolution, '10') return resolution_id async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as desc: + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8').read() + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf-8') as desc: desc.write(base.replace("[img=250]", "[img=250x250]")) images = meta['image_list'] if len(images) > 0: @@ -210,7 +197,7 @@ async def edit_desc(self, meta): desc.close() return - async def search_existing(self, meta): - console.print(f"[red]Dupes must be checked Manually") + async def search_existing(self, meta, disctype): + console.print("[red]Dupes must be checked Manually") return ['Dupes must be checked Manually'] - ### hopefully someone else has the time to implement this. + # hopefully someone else has the time to implement this. diff --git a/src/trackers/BJS.py b/src/trackers/BJS.py new file mode 100644 index 000000000..f50e950b2 --- /dev/null +++ b/src/trackers/BJS.py @@ -0,0 +1,1235 @@ +# -*- coding: utf-8 -*- +import asyncio +import httpx +import json +import langcodes +import os +import platform +import pycountry +import re +import unicodedata +from .COMMON import COMMON +from bs4 import BeautifulSoup +from datetime import datetime +from langcodes.tag_parser import LanguageTagError +from pathlib import Path +from src.console import console +from src.languages import process_desc_language +from src.tmdb import get_tmdb_localized_data +from tqdm import tqdm +from typing import Optional +from urllib.parse import urlparse + + +class BJS(COMMON): + def __init__(self, config): + super().__init__(config) + self.tracker = 'BJS' + self.banned_groups = [''] + self.source_flag = 'BJ' + self.base_url = 'https://bj-share.info' + self.torrent_url = 'https://bj-share.info/torrents.php?torrentid=' + self.announce = self.config['TRACKERS'][self.tracker]['announce_url'] + self.auth_token = None + self.session = httpx.AsyncClient(headers={ + 'User-Agent': f"Upload Assistant/2.3 ({platform.system()} {platform.release()})" + }, timeout=60.0) + self.cover = '' + self.signature = "[center][url=https://github.com/Audionut/Upload-Assistant]Upload realizado via Upload Assistant[/url][/center]" + + async def get_additional_checks(self, meta): + should_continue = True + + # Stops uploading when an external subtitle is detected + video_path = meta.get('path') + directory = video_path if os.path.isdir(video_path) else os.path.dirname(video_path) + subtitle_extensions = ('.srt', '.sub', '.ass', '.ssa', '.idx', '.smi', '.psb') + + if any(f.lower().endswith(subtitle_extensions) for f in os.listdir(directory)): + console.print(f'{self.tracker}: [bold red]ERRO: Esta ferramenta não suporta o upload de legendas em arquivos separados.[/bold red]') + return False + + return should_continue + + async def load_cookies(self, meta): + cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + if not os.path.exists(cookie_file): + console.print(f'[bold red]Arquivo de cookie para o {self.tracker} não encontrado: {cookie_file}[/bold red]') + return False + + self.session.cookies = await self.parseCookieFile(cookie_file) + + async def validate_credentials(self, meta): + await self.load_cookies(meta) + try: + upload_page_url = f'{self.base_url}/upload.php' + response = await self.session.get(upload_page_url, timeout=30.0) + response.raise_for_status() + + if 'login.php' in str(response.url): + console.print(f'[bold red]Falha na validação do {self.tracker}. O cookie parece estar expirado (redirecionado para login).[/bold red]') + return False + + auth_match = re.search(r'name="auth" value="([^"]+)"', response.text) + + if not auth_match: + console.print(f'[bold red]Falha na validação do {self.tracker}. Token Auth não encontrado.[/bold red]') + console.print('[yellow]A estrutura do site pode ter mudado ou o login falhou silenciosamente.[/yellow]') + + failure_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(failure_path, 'w', encoding='utf-8') as f: + f.write(response.text) + console.print(f'[yellow]A resposta do servidor foi salva em {failure_path} para análise.[/yellow]') + return False + + return str(auth_match.group(1)) + + except httpx.TimeoutException: + console.print(f'[bold red]Erro no {self.tracker}: Timeout ao tentar validar credenciais.[/bold red]') + return False + except httpx.HTTPStatusError as e: + console.print(f'[bold red]Erro HTTP ao validar credenciais do {self.tracker}: Status {e.response.status_code}.[/bold red]') + return False + except httpx.RequestError as e: + console.print(f'[bold red]Erro de rede ao validar credenciais do {self.tracker}: {e.__class__.__name__}.[/bold red]') + return False + + def load_localized_data(self, meta): + localized_data_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/tmdb_localized_data.json" + + if os.path.isfile(localized_data_file): + with open(localized_data_file, "r", encoding="utf-8") as f: + self.tmdb_data = json.load(f) + else: + self.tmdb_data = {} + + async def ptbr_tmdb_data(self, meta): + brazil_data_in_meta = self.tmdb_data.get('pt-BR', {}).get('main') + if brazil_data_in_meta: + return brazil_data_in_meta + + data = await get_tmdb_localized_data(meta, data_type='main', language='pt-BR', append_to_response='credits,videos,content_ratings') + self.load_localized_data(meta) + + return data + + def get_container(self, meta): + container = meta.get('container', '') + if container in ['mkv', 'mp4', 'avi', 'vob', 'm2ts', 'ts']: + return container.upper() + + return 'Outro' + + def get_type(self, meta): + if meta.get('anime'): + return '13' + + category_map = { + 'TV': '1', + 'MOVIE': '0' + } + + return category_map.get(meta['category']) + + async def get_languages(self, meta): + possible_languages = { + 'Alemão', 'Árabe', 'Argelino', 'Búlgaro', 'Cantonês', 'Chinês', + 'Coreano', 'Croata', 'Dinamarquês', 'Egípcio', 'Espanhol', 'Estoniano', + 'Filipino', 'Finlandês', 'Francês', 'Grego', 'Hebraico', 'Hindi', + 'Holandês', 'Húngaro', 'Indonésio', 'Inglês', 'Islandês', 'Italiano', + 'Japonês', 'Macedônio', 'Malaio', 'Marati', 'Nigeriano', 'Norueguês', + 'Persa', 'Polaco', 'Polonês', 'Português', 'Português (pt)', 'Romeno', + 'Russo', 'Sueco', 'Tailandês', 'Tamil', 'Tcheco', 'Telugo', 'Turco', + 'Ucraniano', 'Urdu', 'Vietnamita', 'Zulu', 'Outro' + } + tmdb_data = await self.ptbr_tmdb_data(meta) + lang_code = tmdb_data.get('original_language') + origin_countries = tmdb_data.get('origin_country', []) + + if not lang_code: + return 'Outro' + + language_name = None + + if lang_code == 'pt': + if 'PT' in origin_countries: + language_name = 'Português (pt)' + else: + language_name = 'Português' + else: + try: + language_name = langcodes.Language.make(lang_code).display_name('pt').capitalize() + except LanguageTagError: + language_name = lang_code + + if language_name in possible_languages: + return language_name + else: + return 'Outro' + + async def get_audio(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + audio_languages = set(meta.get('audio_languages', [])) + + portuguese_languages = ['Portuguese', 'Português', 'pt'] + + has_pt_audio = any(lang in portuguese_languages for lang in audio_languages) + + original_lang = meta.get('original_language', '').lower() + is_original_pt = original_lang in portuguese_languages + + if has_pt_audio: + if is_original_pt: + return 'Nacional' + elif len(audio_languages) > 1: + return 'Dual Áudio' + else: + return 'Dublado' + + return 'Legendado' + + async def get_subtitle(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + found_language_strings = meta.get('subtitle_languages', []) + + subtitle_type = 'Nenhuma' + + if 'Portuguese' in found_language_strings: + subtitle_type = 'Embutida' + + return subtitle_type + + def get_resolution(self, meta): + if meta.get('is_disc') == 'BDMV': + resolution_str = meta.get('resolution', '') + try: + height_num = int(resolution_str.lower().replace('p', '').replace('i', '')) + height = str(height_num) + + width_num = round((16 / 9) * height_num) + width = str(width_num) + except (ValueError, TypeError): + pass + + else: + video_mi = meta['mediainfo']['media']['track'][1] + width = video_mi['Width'] + height = video_mi['Height'] + + return { + 'width': width, + 'height': height + } + + def get_video_codec(self, meta): + CODEC_MAP = { + 'x265': 'x265', + 'h.265': 'H.265', + 'x264': 'x264', + 'h.264': 'H.264', + 'av1': 'AV1', + 'divx': 'DivX', + 'h.263': 'H.263', + 'kvcd': 'KVCD', + 'mpeg-1': 'MPEG-1', + 'mpeg-2': 'MPEG-2', + 'realvideo': 'RealVideo', + 'vc-1': 'VC-1', + 'vp6': 'VP6', + 'vp8': 'VP8', + 'vp9': 'VP9', + 'windows media video': 'Windows Media Video', + 'xvid': 'XviD', + 'hevc': 'H.265', + 'avc': 'H.264', + } + + video_encode = meta.get('video_encode', '').lower() + video_codec = meta.get('video_codec', '') + + search_text = f'{video_encode} {video_codec.lower()}' + + for key, value in CODEC_MAP.items(): + if key in search_text: + return value + + return video_codec if video_codec else 'Outro' + + def get_audio_codec(self, meta): + priority_order = [ + 'DTS-X', 'E-AC-3 JOC', 'TrueHD', 'DTS-HD', 'LPCM', 'PCM', 'FLAC', + 'DTS-ES', 'DTS', 'E-AC-3', 'AC3', 'AAC', 'Opus', 'Vorbis', 'MP3', 'MP2' + ] + + codec_map = { + 'DTS-X': ['DTS:X', 'DTS-X'], + 'E-AC-3 JOC': ['E-AC-3 JOC', 'DD+ JOC'], + 'TrueHD': ['TRUEHD'], + 'DTS-HD': ['DTS-HD', 'DTSHD'], + 'LPCM': ['LPCM'], + 'PCM': ['PCM'], + 'FLAC': ['FLAC'], + 'DTS-ES': ['DTS-ES'], + 'DTS': ['DTS'], + 'E-AC-3': ['E-AC-3', 'DD+'], + 'AC3': ['AC3', 'DD'], + 'AAC': ['AAC'], + 'Opus': ['OPUS'], + 'Vorbis': ['VORBIS'], + 'MP2': ['MP2'], + 'MP3': ['MP3'] + } + + audio_description = meta.get('audio') + + if not audio_description or not isinstance(audio_description, str): + return 'Outro' + + audio_upper = audio_description.upper() + + for codec_name in priority_order: + search_terms = codec_map.get(codec_name, []) + + for term in search_terms: + if term.upper() in audio_upper: + return codec_name + + return 'Outro' + + async def get_title(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + + title = tmdb_data.get('name') or tmdb_data.get('title') or '' + + return title if title and title != meta.get('title') else '' + + async def build_description(self, meta): + description = [] + + disc_map = { + 'BDMV': ('BD_SUMMARY_00.txt', 'BDInfo'), + 'DVD': ('MEDIAINFO_CLEANPATH.txt', 'MediaInfo'), + } + + disc_type = meta.get('is_disc') + if disc_type in disc_map: + filename, title = disc_map[disc_type] + path = f"{meta['base_dir']}/tmp/{meta['uuid']}/{filename}" + if os.path.exists(path): + with open(path, 'r', encoding='utf-8') as f: + content = f.read() + if content.strip(): + description.append(f'[hide={title}][pre]{content}[/pre][/hide]') + + base_desc = '' + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as f: + base_desc = f.read() + if base_desc: + description.append(base_desc) + + custom_description_header = self.config['DEFAULT'].get('custom_description_header', '') + if custom_description_header: + description.append(custom_description_header) + + description.append(self.signature) + + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + with open(final_desc_path, 'w', encoding='utf-8') as descfile: + desc = '\n\n'.join(filter(None, description)) + desc = re.sub(r"\[spoiler=([^]]+)]", r"[hide=\1]", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[spoiler\]", "[hide]", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[/spoiler\]", "[/hide]", desc, flags=re.IGNORECASE) + desc = re.sub(r'\[img(?:[^\]]*)\]', '[img]', desc, flags=re.IGNORECASE) + final_description = re.sub(r'\n{3,}', '\n\n', desc) + descfile.write(final_description) + + return final_description + + async def get_trailer(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + video_results = tmdb_data.get('videos', {}).get('results', []) + youtube_code = video_results[-1].get('key', '') if video_results else '' + if youtube_code: + youtube = f'http://www.youtube.com/watch?v={youtube_code}' + else: + youtube = meta.get('youtube') or '' + + return youtube + + async def get_rating(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + ratings = tmdb_data.get('content_ratings', {}).get('results', []) + + if not ratings: + return '' + + VALID_BR_RATINGS = {'L', '10', '12', '14', '16', '18'} + + br_rating = '' + us_rating = '' + + for item in ratings: + if item.get('iso_3166_1') == 'BR' and item.get('rating') in VALID_BR_RATINGS: + br_rating = item['rating'] + if br_rating == 'L': + br_rating = 'Livre' + else: + br_rating = f'{br_rating} anos' + break + + # Use US rating as fallback + if item.get('iso_3166_1') == 'US' and not us_rating: + us_rating = item.get('rating', '') + + return br_rating or us_rating or '' + + async def get_tags(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + tags = '' + + if tmdb_data and isinstance(tmdb_data.get('genres'), list): + genre_names = [ + g.get('name', '') for g in tmdb_data['genres'] + if isinstance(g.get('name'), str) and g.get('name').strip() + ] + + if genre_names: + tags = ', '.join( + unicodedata.normalize('NFKD', name) + .encode('ASCII', 'ignore') + .decode('utf-8') + .replace(' ', '.') + .lower() + for name in genre_names + ) + + if not tags: + tags = await asyncio.to_thread(input, f'Digite os gêneros (no formato do {self.tracker}): ') + + return tags + + def _extract_upload_params(self, meta): + is_tv_pack = bool(meta.get('tv_pack')) + upload_season_num = None + upload_episode_num = None + upload_resolution = meta.get('resolution') + + if meta['category'] == 'TV': + season_match = meta.get('season', '').replace('S', '') + if season_match: + upload_season_num = season_match + + if not is_tv_pack: + episode_match = meta.get('episode', '').replace('E', '') + if episode_match: + upload_episode_num = episode_match + + return { + 'is_tv_pack': is_tv_pack, + 'upload_season_num': upload_season_num, + 'upload_episode_num': upload_episode_num, + 'upload_resolution': upload_resolution + } + + def _check_episode_on_page(self, torrent_table, upload_season_num, upload_episode_num): + if not upload_season_num or not upload_episode_num: + return False + + temp_season_on_page = None + upload_episode_str = f'E{upload_episode_num}' + + for row in torrent_table.find_all('tr'): + if 'season_header' in row.get('class', []): + s_match = re.search(r'Temporada (\d+)', row.get_text(strip=True)) + if s_match: + temp_season_on_page = s_match.group(1) + continue + + if (temp_season_on_page == upload_season_num and row.get('id', '').startswith('torrent')): + link = row.find('a', onclick=re.compile(r'loadIfNeeded\(')) + if (link and re.search(r'\b' + re.escape(upload_episode_str) + r'\b', link.get_text(strip=True))): + return True + return False + + def _should_process_torrent(self, row, current_season, current_resolution, params, episode_found_on_page, meta): + description_text = ' '.join(row.find('a', onclick=re.compile(r'loadIfNeeded\(')).get_text(strip=True).split()) + + # TV Logic + if meta['category'] == 'TV': + if current_season == params['upload_season_num']: + existing_episode_match = re.search(r'E(\d+)', description_text) + is_current_row_a_pack = not existing_episode_match + + if params['is_tv_pack']: + return is_current_row_a_pack, False + else: + if episode_found_on_page: + if existing_episode_match: + existing_episode_num = existing_episode_match.group(1) + return existing_episode_num == params['upload_episode_num'], False + else: + return is_current_row_a_pack, True + + # Movie Logic + elif meta['category'] == 'MOVIE': + if params['upload_resolution'] and current_resolution == params['upload_resolution']: + return True, False + + return False, False + + def _extract_torrent_ids(self, rows_to_process): + ajax_tasks = [] + + for row, process_folder_name in rows_to_process: + id_link = row.find('a', onclick=re.compile(r'loadIfNeeded\(')) + if not id_link: + continue + + onclick_attr = id_link['onclick'] + id_match = re.search(r"loadIfNeeded\('(\d+)',\s*'(\d+)'", onclick_attr) + if not id_match: + continue + + torrent_id = id_match.group(1) + group_id = id_match.group(2) + description_text = ' '.join(id_link.get_text(strip=True).split()) + torrent_link = f'{self.torrent_url}{torrent_id}' + + size_tag = row.find('td', class_='number_column nobr') + torrent_size_str = size_tag.get_text(strip=True) if size_tag else None + + ajax_tasks.append({ + 'torrent_id': torrent_id, + 'group_id': group_id, + 'description_text': description_text, + 'process_folder_name': process_folder_name, + 'size': torrent_size_str, + 'link': torrent_link + }) + + return ajax_tasks + + async def _fetch_torrent_content(self, task_info): + torrent_id = task_info['torrent_id'] + group_id = task_info['group_id'] + ajax_url = f'{self.base_url}/ajax.php?action=torrent_content&torrentid={torrent_id}&groupid={group_id}' + + try: + ajax_response = await self.session.get(ajax_url) + ajax_response.raise_for_status() + ajax_soup = BeautifulSoup(ajax_response.text, 'html.parser') + + return { + 'success': True, + 'soup': ajax_soup, + 'task_info': task_info + } + except Exception as e: + console.print(f'[yellow]Não foi possível buscar a lista de arquivos para o torrent {torrent_id}: {e}[/yellow]') + return { + 'success': False, + 'error': e, + 'task_info': task_info + } + + def _extract_item_name(self, ajax_soup, description_text, is_tv_pack, process_folder_name): + item_name = None + is_existing_torrent_a_disc = any( + keyword in description_text.lower() + for keyword in ['bd25', 'bd50', 'bd66', 'bd100', 'dvd5', 'dvd9', 'm2ts'] + ) + + if is_existing_torrent_a_disc or is_tv_pack or process_folder_name: + path_div = ajax_soup.find('div', class_='filelist_path') + if path_div and path_div.get_text(strip=True): + item_name = path_div.get_text(strip=True).strip('/') + else: + file_table = ajax_soup.find('table', class_='filelist_table') + if file_table: + first_file_row = file_table.find('tr', class_=lambda x: x != 'colhead_dark') + if first_file_row and first_file_row.find('td'): + item_name = first_file_row.find('td').get_text(strip=True) + else: + file_table = ajax_soup.find('table', class_='filelist_table') + if file_table: + first_row = file_table.find('tr', class_=lambda x: x != 'colhead_dark') + if first_row and first_row.find('td'): + item_name = first_row.find('td').get_text(strip=True) + + return item_name + + async def _process_ajax_responses(self, ajax_tasks, params): + if not ajax_tasks: + return [] + + ajax_results = await asyncio.gather( + *[self._fetch_torrent_content(task) for task in ajax_tasks], + return_exceptions=True + ) + + found_items = [] + for result in ajax_results: + if isinstance(result, Exception): + console.print(f'[yellow]Erro na chamada AJAX: {result}[/yellow]') + continue + + if not result['success']: + continue + + task_info = result['task_info'] + item_name = self._extract_item_name( + result['soup'], + task_info['description_text'], + params['is_tv_pack'], + task_info['process_folder_name'] + ) + + if item_name: + found_items.append({ + 'name': item_name, + 'size': task_info.get('size', ''), + 'link': task_info.get('link', '') + }) + + return found_items + + async def _fetch_search_page(self, meta): + search_url = f"{self.base_url}/torrents.php?searchstr={meta['imdb_info']['imdbID']}" + + response = await self.session.get(search_url) + if response.status_code in [301, 302, 307] and 'Location' in response.headers: + redirect_url = f"{self.base_url}/{response.headers['Location']}" + response = await self.session.get(redirect_url) + response.raise_for_status() + + return BeautifulSoup(response.text, 'html.parser') + + async def search_existing(self, meta, disctype): + should_continue = await self.get_additional_checks(meta) + if not should_continue: + meta['skipping'] = f'{self.tracker}' + return + + try: + params = self._extract_upload_params(meta) + + soup = await self._fetch_search_page(meta) + torrent_details_table = soup.find('div', class_='main_column') + + if not torrent_details_table: + return [] + + episode_found_on_page = False + if (meta['category'] == 'TV' and not params['is_tv_pack'] and params['upload_season_num'] and params['upload_episode_num']): + episode_found_on_page = self._check_episode_on_page( + torrent_details_table, + params['upload_season_num'], + params['upload_episode_num'] + ) + + rows_to_process = [] + current_season_on_page = None + current_resolution_on_page = None + + for row in torrent_details_table.find_all('tr'): + if 'resolution_header' in row.get('class', []): + header_text = row.get_text(strip=True) + resolution_match = re.search(r'(\d{3,4}p)', header_text) + if resolution_match: + current_resolution_on_page = resolution_match.group(1) + continue + + if 'season_header' in row.get('class', []): + season_header_text = row.get_text(strip=True) + season_match = re.search(r'Temporada (\d+)', season_header_text) + if season_match: + current_season_on_page = season_match.group(1) + continue + + if not row.get('id', '').startswith('torrent'): + continue + + id_link = row.find('a', onclick=re.compile(r'loadIfNeeded\(')) + if not id_link: + continue + + should_process, process_folder_name = self._should_process_torrent( + row, current_season_on_page, current_resolution_on_page, + params, episode_found_on_page, meta + ) + + if should_process: + rows_to_process.append((row, process_folder_name)) + + ajax_tasks = self._extract_torrent_ids(rows_to_process) + found_items = await self._process_ajax_responses(ajax_tasks, params) + + return found_items + + except Exception as e: + console.print(f'[bold red]Ocorreu um erro inesperado ao processar a busca: {e}[/bold red]') + import traceback + traceback.print_exc() + return [] + + def get_edition(self, meta): + edition_str = meta.get('edition', '').lower() + if not edition_str: + return '' + + edition_map = { + "director's cut": "Director's Cut", + 'extended': 'Extended Edition', + 'imax': 'IMAX', + 'open matte': 'Open Matte', + 'noir': 'Noir Edition', + 'theatrical': 'Theatrical Cut', + 'uncut': 'Uncut', + 'unrated': 'Unrated', + 'uncensored': 'Uncensored', + } + + for keyword, label in edition_map.items(): + if keyword in edition_str: + return label + + return '' + + def get_bitrate(self, meta): + if meta.get('type') == 'DISC': + is_disc_type = meta.get('is_disc') + + if is_disc_type == 'BDMV': + disctype = meta.get('disctype') + if disctype in ['BD100', 'BD66', 'BD50', 'BD25']: + return disctype + + try: + size_in_gb = meta['bdinfo']['size'] + except (KeyError, IndexError, TypeError): + size_in_gb = 0 + + if size_in_gb > 66: + return 'BD100' + elif size_in_gb > 50: + return 'BD66' + elif size_in_gb > 25: + return 'BD50' + else: + return 'BD25' + + elif is_disc_type == 'DVD': + dvd_size = meta.get('dvd_size') + if dvd_size in ['DVD9', 'DVD5']: + return dvd_size + return 'DVD9' + + source_type = meta.get('type') + + if not source_type or not isinstance(source_type, str): + return 'Outro' + + keyword_map = { + 'webdl': 'WEB-DL', + 'webrip': 'WEBRip', + 'web': 'WEB', + 'remux': 'Blu-ray', + 'encode': 'Blu-ray', + 'bdrip': 'BDRip', + 'brrip': 'BRRip', + 'hdtv': 'HDTV', + 'sdtv': 'SDTV', + 'dvdrip': 'DVDRip', + 'hd-dvd': 'HD DVD', + 'dvdscr': 'DVDScr', + 'hdrip': 'HDRip', + 'hdtc': 'HDTC', + 'hdtv': 'HDTV', + 'pdtv': 'PDTV', + 'sdtv': 'SDTV', + 'tc': 'TC', + 'uhdtv': 'UHDTV', + 'vhsrip': 'VHSRip', + 'tvrip': 'TVRip', + } + + return keyword_map.get(source_type.lower(), 'Outro') + + async def img_host(self, image_bytes: bytes, filename: str) -> Optional[str]: + upload_url = f'{self.base_url}/ajax.php?action=screen_up' + headers = { + 'Referer': f'{self.base_url}/upload.php', + 'X-Requested-With': 'XMLHttpRequest', + 'Accept': 'application/json', + } + files = {'file': (filename, image_bytes, 'image/png')} + + try: + response = await self.session.post( + upload_url, headers=headers, files=files, timeout=120 + ) + response.raise_for_status() + data = response.json() + return data.get('url', '').replace('\\/', '/') + except Exception as e: + print(f'Exceção no upload de {filename}: {e}') + return None + + async def get_cover(self, meta, disctype): + tmdb_data = await self.ptbr_tmdb_data(meta) + cover_path = tmdb_data.get('poster_path') or meta.get('tmdb_poster') + if not cover_path: + print('Nenhum poster_path encontrado nos dados do TMDB.') + return None + + cover_tmdb_url = f'https://image.tmdb.org/t/p/w500{cover_path}' + try: + response = await self.session.get(cover_tmdb_url, timeout=120) + response.raise_for_status() + image_bytes = response.content + filename = os.path.basename(cover_path) + + return await self.img_host(image_bytes, filename) + except Exception as e: + print(f'Falha ao processar pôster da URL {cover_tmdb_url}: {e}') + return None + + async def get_screenshots(self, meta): + screenshot_dir = Path(meta['base_dir']) / 'tmp' / meta['uuid'] + local_files = sorted(screenshot_dir.glob('*.png')) + results = [] + + # Use existing files + if local_files: + async def upload_local_file(path): + with open(path, 'rb') as f: + image_bytes = f.read() + return await self.img_host(image_bytes, os.path.basename(path)) + + paths = local_files[:6] + + for coro in tqdm( + asyncio.as_completed([upload_local_file(p) for p in paths]), + total=len(paths), + desc=f'Uploading screenshots to {self.tracker}', + ): + result = await coro + if result: + results.append(result) + + else: + image_links = [ + img.get('raw_url') + for img in meta.get('image_list', []) + if img.get('raw_url') + ][:6] + + async def upload_remote_file(url): + try: + response = await self.session.get(url, timeout=120) + response.raise_for_status() + image_bytes = response.content + filename = os.path.basename(urlparse(url).path) or 'screenshot.png' + return await self.img_host(image_bytes, filename) + except Exception as e: + print(f'Falha ao processar screenshot da URL {url}: {e}') + return None + + for coro in tqdm( + asyncio.as_completed([upload_remote_file(url) for url in image_links]), + total=len(image_links), + desc=f'Uploading screenshots to {self.tracker}', + ): + result = await coro + if result: + results.append(result) + + return results + + def get_runtime(self, meta): + try: + minutes_in_total = int(meta.get('runtime')) + if minutes_in_total < 0: + return 0, 0 + except (ValueError, TypeError): + return 0, 0 + + hours, minutes = divmod(minutes_in_total, 60) + return { + 'hours': hours, + 'minutes': minutes + } + + def get_release_date(self, tmdb_data): + raw_date_string = tmdb_data.get('first_air_date') or tmdb_data.get('release_date') + + if not raw_date_string: + return '' + + try: + date_object = datetime.strptime(raw_date_string, '%Y-%m-%d') + formatted_date = date_object.strftime('%d %b %Y') + + return formatted_date + + except ValueError: + return '' + + def find_remaster_tags(self, meta): + found_tags = set() + + edition = self.get_edition(meta) + if edition: + found_tags.add(edition) + + audio_string = meta.get('audio', '') + if 'Atmos' in audio_string: + found_tags.add('Dolby Atmos') + + is_10_bit = False + if meta.get('is_disc') == 'BDMV': + try: + bit_depth_str = meta['discs'][0]['bdinfo']['video'][0]['bit_depth'] + if '10' in bit_depth_str: + is_10_bit = True + except (KeyError, IndexError, TypeError): + pass + else: + if str(meta.get('bit_depth')) == '10': + is_10_bit = True + + if is_10_bit: + found_tags.add('10-bit') + + hdr_string = meta.get('hdr', '').upper() + if 'DV' in hdr_string: + found_tags.add('Dolby Vision') + if 'HDR10+' in hdr_string: + found_tags.add('HDR10+') + if 'HDR' in hdr_string and 'HDR10+' not in hdr_string: + found_tags.add('HDR10') + + if meta.get('type') == 'REMUX': + found_tags.add('Remux') + if meta.get('extras'): + found_tags.add('Com extras') + if meta.get('has_commentary', False) or meta.get('manual_commentary', False): + found_tags.add('Com comentários') + + return found_tags + + def build_remaster_title(self, meta): + tag_priority = [ + 'Dolby Atmos', + 'Remux', + "Director's Cut", + 'Extended Edition', + 'IMAX', + 'Open Matte', + 'Noir Edition', + 'Theatrical Cut', + 'Uncut', + 'Unrated', + 'Uncensored', + '10-bit', + 'Dolby Vision', + 'HDR10+', + 'HDR10', + 'Com extras', + 'Com comentários' + ] + available_tags = self.find_remaster_tags(meta) + + ordered_tags = [] + for tag in tag_priority: + if tag in available_tags: + ordered_tags.append(tag) + + return ' / '.join(ordered_tags) + + def get_credits(self, meta, role): + role_map = { + 'director': ('directors', 'tmdb_directors'), + 'creator': ('creators', 'tmdb_creators'), + 'cast': ('stars', 'tmdb_cast'), + } + + prompt_name_map = { + 'director': 'Diretor(es)', + 'creator': 'Criador(es)', + 'cast': 'Elenco', + } + + if role in role_map: + imdb_key, tmdb_key = role_map[role] + + names = (meta.get('imdb_info', {}).get(imdb_key) or []) + (meta.get(tmdb_key) or []) + + unique_names = list(dict.fromkeys(names))[:5] + if unique_names: + return ', '.join(unique_names) + + else: + if not self.cover: # Only ask for input if there's no info in the site already + role_display_name = prompt_name_map.get(role, role.capitalize()) + prompt_message = (f'{role_display_name} não encontrado(s).\nPor favor, insira manualmente (separados por vírgula): ') + user_input = input(prompt_message) + + if user_input.strip(): + return user_input.strip() + else: + return 'skipped' + else: + return 'N/A' + + async def get_requests(self, meta): + if not self.config['DEFAULT'].get('search_requests', False) and not meta.get('search_requests', False): + return False + else: + try: + cat = meta['category'] + if cat == 'TV': + cat = 2 + if cat == 'MOVIE': + cat = 1 + if meta.get('anime'): + cat = 14 + + query = meta['title'] + + search_url = f'{self.base_url}/requests.php?submit=true&search={query}&showall=on&filter_cat[{cat}]=1' + + response = await self.session.get(search_url) + response.raise_for_status() + response_results_text = response.text + + soup = BeautifulSoup(response_results_text, 'html.parser') + + request_rows = soup.select('#torrent_table tr.torrent') + + results = [] + for row in request_rows: + all_tds = row.find_all('td') + if not all_tds or len(all_tds) < 5: + continue + + info_cell = all_tds[1] + + link_element = info_cell.select_one('a[href*="requests.php?action=view"]') + quality_element = info_cell.select_one('b') + + if not link_element or not quality_element: + continue + + name = link_element.text.strip() + quality = quality_element.text.strip() + link = link_element.get('href') + + reward_td = all_tds[3] + reward_parts = [td.text.replace('\xa0', ' ').strip() for td in reward_td.select('tr > td:first-child')] + reward = ' / '.join(reward_parts) + + results.append({ + 'Name': name, + 'Quality': quality, + 'Reward': reward, + 'Link': link, + }) + + if results: + message = f'\n{self.tracker}: [bold yellow]Seu upload pode atender o(s) seguinte(s) pedido(s), confira:[/bold yellow]\n\n' + for r in results: + message += f"[bold green]Nome:[/bold green] {r['Name']}\n" + message += f"[bold green]Qualidade:[/bold green] {r['Quality']}\n" + message += f"[bold green]Recompensa:[/bold green] {r['Reward']}\n" + message += f"[bold green]Link:[/bold green] {self.base_url}/{r['Link']}\n\n" + console.print(message) + + return results + + except Exception as e: + console.print(f'[bold red]Ocorreu um erro ao buscar pedido(s) no {self.tracker}: {e}[/bold red]') + import traceback + console.print(traceback.format_exc()) + return [] + + async def fetch_data(self, meta, disctype): + await self.load_cookies(meta) + self.load_localized_data(meta) + tmdb_data = await self.ptbr_tmdb_data(meta) + category = meta['category'] + + data = {} + + # These fields are common across all upload types + data.update({ + 'audio': await self.get_audio(meta), + 'auth': meta[f'{self.tracker}_secret_token'], + 'codecaudio': self.get_audio_codec(meta), + 'codecvideo': self.get_video_codec(meta), + 'duracaoHR': self.get_runtime(meta).get('hours'), + 'duracaoMIN': self.get_runtime(meta).get('minutes'), + 'duracaotipo': 'selectbox', + 'fichatecnica': await self.build_description(meta), + 'formato': self.get_container(meta), + 'idioma': await self.get_languages(meta), + 'imdblink': meta['imdb_info']['imdbID'], + 'qualidade': self.get_bitrate(meta), + 'release': meta.get('service_longname', ''), + 'remaster_title': self.build_remaster_title(meta), + 'resolucaoh': self.get_resolution(meta).get('height'), + 'resolucaow': self.get_resolution(meta).get('width'), + 'sinopse': tmdb_data.get('overview') or await asyncio.to_thread(input, 'Digite a sinopse: '), + 'submit': 'true', + 'tags': await self.get_tags(meta), + 'tipolegenda': await self.get_subtitle(meta), + 'title': meta['title'], + 'titulobrasileiro': await self.get_title(meta), + 'traileryoutube': await self.get_trailer(meta), + 'type': self.get_type(meta), + 'year': f"{meta['year']}-{meta['imdb_info']['end_year']}" if meta.get('imdb_info').get('end_year') else meta['year'], + }) + + # These fields are common in movies and TV shows, even if it's anime + if category == 'MOVIE': + data.update({ + 'adulto': '2', + 'diretor': self.get_credits(meta, 'director'), + }) + + if category == 'TV': + data.update({ + 'diretor': self.get_credits(meta, 'creator'), + 'tipo': 'episode' if meta.get('tv_pack') == 0 else 'season', + 'season': meta.get('season_int', ''), + 'episode': meta.get('episode_int', ''), + }) + + # These fields are common in movies and TV shows, if not Anime + if not meta.get('anime'): + data.update({ + 'validimdb': 'yes', + 'imdbrating': str(meta.get('imdb_info', {}).get('rating', '')), + 'elenco': self.get_credits(meta, 'cast'), + }) + if category == 'MOVIE': + data.update({ + 'datalancamento': self.get_release_date(tmdb_data), + }) + + if category == 'TV': + # Convert country code to name + country_list = [ + country.name + for code in tmdb_data.get('origin_country', []) + if (country := pycountry.countries.get(alpha_2=code)) + ] + data.update({ + 'network': ', '.join([p.get('name', '') for p in tmdb_data.get('networks', [])]) or '', # Optional + 'numtemporadas': tmdb_data.get('number_of_seasons', ''), # Optional + 'datalancamento': self.get_release_date(tmdb_data), + 'pais': ', '.join(country_list), # Optional + 'diretorserie': ', '.join(set(meta.get('tmdb_directors', []) or meta.get('imdb_info', {}).get('directors', [])[:5])), # Optional + 'avaliacao': await self.get_rating(meta), # Optional + }) + + # Anime-specific data + if meta.get('anime'): + if category == 'MOVIE': + data.update({ + 'tipo': 'movie', + }) + if category == 'TV': + data.update({ + 'adulto': '2', + }) + + # Anon + anon = not (meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False)) + if anon: + data.update({ + 'anonymous': 'on' + }) + if self.config['TRACKERS'][self.tracker].get('show_group_if_anon', False): + data.update({ + 'anonymousshowgroup': 'on' + }) + + # Internal + if self.config['TRACKERS'][self.tracker].get('internal', False) is True: + data.update({ + 'internalrel': 1, + }) + + # Only upload images if not debugging + if not meta.get('debug', False): + data.update({ + 'image': await self.get_cover(meta, disctype), + 'screenshots[]': await self.get_screenshots(meta), + }) + + return data + + async def check_data(self, meta, data): + if not meta.get('debug', False): + if len(data['screenshots[]']) < 2: + return 'The number of successful screenshots uploaded is less than 2.' + if any(value == 'skipped' for value in ( + data.get('diretor'), + data.get('elenco'), + data.get('creators') + )): + return 'Missing required credits information (director/cast/creator).' + return False + + async def upload(self, meta, disctype): + status_message = '' + data = await self.fetch_data(meta, disctype) + requests = await self.get_requests(meta) + await self.edit_torrent(meta, self.tracker, self.source_flag) + + issue = await self.check_data(meta, data) + if issue: + status_message = f'data error - {issue}' + else: + if not meta.get('debug', False): + torrent_id = '' + upload_url = f"{self.base_url}/upload.php" + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = {'file_input': (f"{self.tracker}.placeholder.torrent", torrent_file, 'application/x-bittorrent')} + + response = await self.session.post(upload_url, data=data, files=files, timeout=120) + + if 'action=download&id=' in response.text: + status_message = 'Enviado com sucesso.' + + # Find the torrent id + match = re.search(r'torrentid=(\d+)', response.text) + if match: + torrent_id = match.group(1) + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + if requests: + status_message += ' Seu upload pode atender a pedidos existentes, verifique os logs anteriores do console.' + + else: + status_message = 'data error - O upload pode ter falhado, verifique. ' + response_save_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(response_save_path, 'w', encoding='utf-8') as f: + f.write(response.text) + console.print(f'Falha no upload, a resposta HTML foi salva em: {response_save_path}') + + await self.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce, self.torrent_url + torrent_id) + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading.' + + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/BLU.py b/src/trackers/BLU.py index 33e03975b..56177d8ba 100644 --- a/src/trackers/BLU.py +++ b/src/trackers/BLU.py @@ -1,217 +1,154 @@ # -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os -import platform - -from src.trackers.COMMON import COMMON from src.console import console +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + -class BLU(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ +class BLU(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='BLU') self.config = config + self.common = COMMON(config) self.tracker = 'BLU' self.source_flag = 'BLU' - self.search_url = 'https://blutopia.cc/api/torrents/filter' - self.torrent_url = 'https://blutopia.cc/api/torrents/' - self.upload_url = 'https://blutopia.cc/api/torrents/upload' - self.signature = f"\n[center][url=https://blutopia.cc/forums/topics/3087]Created by L4G's Upload Assistant[/url][/center]" + self.base_url = 'https://blutopia.cc' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.torrent_url = f'{self.base_url}/torrents/' self.banned_groups = [ - '[Oj]', '3LTON', '4yEo', 'AFG', 'AniHLS', 'AnimeRG', 'AniURL', 'AROMA', 'aXXo', 'Brrip', 'CM8', 'CrEwSaDe', 'd3g', 'DeadFish', 'DNL', 'ELiTE', 'eSc', 'FaNGDiNG0', 'FGT', 'Flights', - 'FRDS', 'FUM', 'HAiKU', 'HD2DVD', 'HDS', 'HDTime', 'Hi10', 'ION10', 'iPlanet', 'JIVE', 'KiNGDOM', 'Leffe', 'LEGi0N', 'LOAD', 'MeGusta', 'mHD', 'mSD', 'NhaNc3', 'nHD', 'nikt0', 'NOIVTC', - 'nSD', 'PiRaTeS', 'playBD', 'PlaySD', 'playXD', 'PRODJi', 'RAPiDCOWS', 'RARBG', 'RDN', 'REsuRRecTioN', 'RMTeam', 'SANTi', 'SicFoI', 'SPASM', 'STUTTERSHIT', 'Telly', 'TM', 'TRiToN', 'UPiNSMOKE', - 'URANiME', 'WAF', 'x0r', 'xRed', 'XS', 'YIFY', 'ZKBL', 'ZmN', 'ZMNT', - ['EVO', 'Raw Content Only'], ['TERMiNAL', 'Raw Content Only'], ['ViSION', 'Note the capitalization and characters used'], ['CMRG', 'Raw Content Only'] + '[Oj]', '3LTON', '4yEo', 'ADE', 'AFG', 'AniHLS', 'AnimeRG', 'AniURL', 'AOC', 'AROMA', + 'aXXo', 'Brrip', 'CHD', 'CM8', 'CrEwSaDe', 'd3g', 'DeadFish', 'DNL', 'ELiTE', 'eSc', + 'FaNGDiNG0', 'FGT', 'Flights', 'FRDS', 'FUM', 'HAiKU', 'HD2DVD', 'HDS', 'HDTime', + 'Hi10', 'ION10', 'iPlanet', 'JIVE', 'KiNGDOM', 'Leffe', 'LEGi0N', 'LOAD', 'MeGusta', + 'mHD', 'mSD', 'NhaNc3', 'nHD', 'nikt0', 'NOIVTC', 'nSD', 'OFT', 'PiRaTeS', 'playBD', + 'PlaySD', 'playXD', 'PRODJi', 'RAPiDCOWS', 'RARBG', 'RDN', 'REsuRRecTioN', 'RetroPeeps', + 'RMTeam', 'SANTi', 'SicFoI', 'SPASM', 'SPDVD', 'STUTTERSHIT', 'Telly', 'TM', 'TRiToN', + 'UPiNSMOKE', 'URANiME', 'WAF', 'x0r', 'xRed', 'XS', 'YIFY', 'ZKBL', 'ZmN', 'ZMNT', + ['CMRG', 'Raw Content Only'], ['EVO', 'Raw Content Only'], ['TERMiNAL', 'Raw Content Only'], + ['ViSION', 'Note the capitalization and characters used'], ] - pass - - async def upload(self, meta): - common = COMMON(config=self.config) - + + async def get_name(self, meta): blu_name = meta['name'] - desc_header = "" + if meta['category'] == 'TV' and meta.get('episode_title', "") != "": + blu_name = blu_name.replace(f"{meta['episode_title']} {meta['resolution']}", f"{meta['resolution']}", 1) + imdb_name = meta.get('imdb_info', {}).get('title', "") + imdb_year = str(meta.get('imdb_info', {}).get('year', "")) + year = str(meta.get('year', "")) + blu_name = blu_name.replace(f"{meta['title']}", imdb_name, 1) + if not meta.get('category') == "TV": + blu_name = blu_name.replace(f"{year}", imdb_year, 1) + + if all([x in meta['hdr'] for x in ['HDR', 'DV']]): + if "hybrid" not in blu_name.lower(): + if "REPACK" in blu_name: + blu_name = blu_name.replace('REPACK', 'Hybrid REPACK') + else: + blu_name = blu_name.replace(meta['resolution'], f"Hybrid {meta['resolution']}") + + return {'name': blu_name} + + async def get_description(self, meta): + desc_header = '' if meta.get('webdv', False): - blu_name, desc_header = await self.derived_dv_layer(meta) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await common.unit3d_edit_desc(meta, self.tracker, self.signature, comparison=True, desc_header=desc_header) - cat_id = await self.get_cat_id(meta['category'], meta.get('edition', '')) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 + desc_header = await self.derived_dv_layer(meta) + await self.common.unit3d_edit_desc(meta, self.tracker, self.signature, comparison=True, desc_header=desc_header) + desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8').read() + return {'description': desc} - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[BLU]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[BLU]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': ("placeholder.torrent", open_torrent, "application/x-bittorrent")} - data = { - 'name' : blu_name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() + async def derived_dv_layer(self, meta): + desc_header = '' + # Exit if not DV + HDR + if not all([x in meta['hdr'] for x in ['HDR', 'DV']]): + return desc_header + import cli_ui + console.print("[bold yellow]Generating the required description addition for Derived DV Layers. Please respond appropriately.") + ask_comp = True + if meta['type'] == 'WEBDL': + if cli_ui.ask_yes_no("Is the DV Layer sourced from the same service as the video?"): + ask_comp = False + desc_header = "[code]This release contains a derived Dolby Vision profile 8 layer. Comparisons not required as DV and HDR are from same provider.[/code]" + if ask_comp: + while desc_header == "": + desc_input = cli_ui.ask_string("Please provide comparisons between HDR masters. (link or bbcode)", default="") + desc_header = f"[code]This release contains a derived Dolby Vision profile 8 layer. Comparisons between HDR masters: {desc_input}[/code]" + return desc_header + async def get_additional_data(self, meta): + data = { + 'modq': await self.get_flag(meta, 'modq'), + } + return data - async def get_cat_id(self, category_name, edition): + async def get_category_id(self, meta, mapping_only=False, reverse=False, category=None): + edition = meta.get('edition', '') + category_name = meta['category'] category_id = { - 'MOVIE': '1', - 'TV': '2', + 'MOVIE': '1', + 'TV': '2', 'FANRES': '3' - }.get(category_name, '0') + } if category_name == 'MOVIE' and 'FANRES' in edition: category_id = '3' - return category_id + if mapping_only: + return category_id + elif reverse: + return {v: k for k, v in category_id.items()} + elif category is not None: + return {'category_id': category_id.get(category, '0')} + else: + meta_category = meta.get('category', '') + resolved_id = category_id.get(meta_category, '0') + return {'category_id': resolved_id} - async def get_type_id(self, type): + async def get_type_id(self, meta, type=None, reverse=False, mapping_only=False): type_id = { - 'DISC': '1', + 'DISC': '1', 'REMUX': '3', - 'WEBDL': '4', - 'WEBRIP': '5', + 'WEBDL': '4', + 'WEBRIP': '5', 'HDTV': '6', 'ENCODE': '12' - }.get(type, '0') - return type_id + } - async def get_res_id(self, resolution): + if mapping_only: + return type_id + elif reverse: + return {v: k for k, v in type_id.items()} + elif type is not None: + return {'type_id': type_id.get(type, '0')} + else: + meta_type = meta.get('type', '') + resolved_id = type_id.get(meta_type, '0') + return {'type_id': resolved_id} + + async def get_resolution_id(self, meta, mapping_only=False, reverse=False, resolution=None): resolution_id = { - '8640p':'10', - '4320p': '11', - '2160p': '1', - '1440p' : '2', + '8640p': '10', + '4320p': '11', + '2160p': '1', + '1440p': '2', '1080p': '2', - '1080i':'3', - '720p': '5', - '576p': '6', + '1080i': '3', + '720p': '5', + '576p': '6', '576i': '7', - '480p': '8', + '480p': '8', '480i': '9' - }.get(resolution, '10') - return resolution_id - - async def derived_dv_layer(self, meta): - name = meta['name'] - desc_header = "" - # Exit if not DV + HDR - if not all([x in meta['hdr'] for x in ['HDR', 'DV']]): - return name, desc_header - import cli_ui - console.print("[bold yellow]Generating the required description addition for Derived DV Layers. Please respond appropriately.") - ask_comp = True - if meta['type'] == "WEBDL": - if cli_ui.ask_yes_no("Is the DV Layer sourced from the same service as the video?"): - ask_comp = False - desc_header = "[code]This release contains a derived Dolby Vision profile 8 layer. Comparisons not required as DV and HDR are from same provider.[/code]" - - if ask_comp: - while desc_header == "": - desc_input = cli_ui.ask_string("Please provide comparisons between HDR masters. (link or bbcode)", default="") - desc_header = f"[code]This release contains a derived Dolby Vision profile 8 layer. Comparisons between HDR masters: {desc_input}[/code]" - - if "hybrid" not in name.lower(): - if "REPACK" in name: - name = name.replace('REPACK', 'Hybrid REPACK') - else: - name = name.replace(meta['resolution'], f"Hybrid {meta['resolution']}") - return name, desc_header - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category'], meta.get('edition', '')), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" } - if meta['category'] == 'TV': - params['name'] = params['name'] + f" {meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + f" {meta['edition']}" - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes \ No newline at end of file + if mapping_only: + return resolution_id + elif reverse: + return {v: k for k, v in resolution_id.items()} + elif resolution is not None: + return {'resolution_id': resolution_id.get(resolution, '10')} + else: + meta_resolution = meta.get('resolution', '') + resolved_id = resolution_id.get(meta_resolution, '10') + return {'resolution_id': resolved_id} diff --git a/src/trackers/BT.py b/src/trackers/BT.py new file mode 100644 index 000000000..4ac98f5ab --- /dev/null +++ b/src/trackers/BT.py @@ -0,0 +1,686 @@ +# -*- coding: utf-8 -*- +import asyncio +import httpx +import json +import langcodes +import os +import platform +import re +import unicodedata +from bs4 import BeautifulSoup +from langcodes.tag_parser import LanguageTagError +from src.console import console +from src.languages import process_desc_language +from src.tmdb import get_tmdb_localized_data +from src.trackers.COMMON import COMMON + + +class BT(): + def __init__(self, config): + self.config = config + self.common = COMMON(config) + self.tracker = 'BT' + self.banned_groups = [''] + self.source_flag = 'BT' + self.base_url = 'https://brasiltracker.org' + self.torrent_url = f'{self.base_url}/torrents.php?id=' + self.announce = self.config['TRACKERS'][self.tracker]['announce_url'] + self.auth_token = None + self.session = httpx.AsyncClient(headers={ + 'User-Agent': f"Upload Assistant/2.3 ({platform.system()} {platform.release()})" + }, timeout=60.0) + self.signature = "[center][url=https://github.com/Audionut/Upload-Assistant]Upload realizado via Upload Assistant[/url][/center]" + + target_site_ids = { + 'arabic': '22', 'bulgarian': '29', 'chinese': '14', 'croatian': '23', + 'czech': '30', 'danish': '10', 'dutch': '9', 'english - forçada': '50', + 'english': '3', 'estonian': '38', 'finnish': '15', 'french': '5', + 'german': '6', 'greek': '26', 'hebrew': '40', 'hindi': '41', + 'hungarian': '24', 'icelandic': '28', 'indonesian': '47', 'italian': '16', + 'japanese': '8', 'korean': '19', 'latvian': '37', 'lithuanian': '39', + 'norwegian': '12', 'persian': '52', 'polish': '17', 'português': '49', + 'romanian': '13', 'russian': '7', 'serbian': '31', 'slovak': '42', + 'slovenian': '43', 'spanish': '4', 'swedish': '11', 'thai': '20', + 'turkish': '18', 'ukrainian': '34', 'vietnamese': '25', + } + + source_alias_map = { + ('Arabic', 'ara', 'ar'): 'arabic', + ('Brazilian Portuguese', 'Brazilian', 'Portuguese-BR', 'pt-br', 'pt-BR', 'Portuguese', 'por', 'pt', 'pt-PT', 'Português Brasileiro', 'Português'): 'português', + ('Bulgarian', 'bul', 'bg'): 'bulgarian', + ('Chinese', 'chi', 'zh', 'Chinese (Simplified)', 'Chinese (Traditional)', 'cmn-Hant', 'cmn-Hans', 'yue-Hant', 'yue-Hans'): 'chinese', + ('Croatian', 'hrv', 'hr', 'scr'): 'croatian', + ('Czech', 'cze', 'cz', 'cs'): 'czech', + ('Danish', 'dan', 'da'): 'danish', + ('Dutch', 'dut', 'nl'): 'dutch', + ('English - Forced', 'English (Forced)', 'en (Forced)', 'en-US (Forced)'): 'english - forçada', + ('English', 'eng', 'en', 'en-US', 'en-GB', 'English (CC)', 'English - SDH'): 'english', + ('Estonian', 'est', 'et'): 'estonian', + ('Finnish', 'fin', 'fi'): 'finnish', + ('French', 'fre', 'fr', 'fr-FR', 'fr-CA'): 'french', + ('German', 'ger', 'de'): 'german', + ('Greek', 'gre', 'el'): 'greek', + ('Hebrew', 'heb', 'he'): 'hebrew', + ('Hindi', 'hin', 'hi'): 'hindi', + ('Hungarian', 'hun', 'hu'): 'hungarian', + ('Icelandic', 'ice', 'is'): 'icelandic', + ('Indonesian', 'ind', 'id'): 'indonesian', + ('Italian', 'ita', 'it'): 'italian', + ('Japanese', 'jpn', 'ja'): 'japanese', + ('Korean', 'kor', 'ko'): 'korean', + ('Latvian', 'lav', 'lv'): 'latvian', + ('Lithuanian', 'lit', 'lt'): 'lithuanian', + ('Norwegian', 'nor', 'no'): 'norwegian', + ('Persian', 'fa', 'far'): 'persian', + ('Polish', 'pol', 'pl'): 'polish', + ('Romanian', 'rum', 'ro'): 'romanian', + ('Russian', 'rus', 'ru'): 'russian', + ('Serbian', 'srp', 'sr', 'scc'): 'serbian', + ('Slovak', 'slo', 'sk'): 'slovak', + ('Slovenian', 'slv', 'sl'): 'slovenian', + ('Spanish', 'spa', 'es', 'es-ES', 'es-419'): 'spanish', + ('Swedish', 'swe', 'sv'): 'swedish', + ('Thai', 'tha', 'th'): 'thai', + ('Turkish', 'tur', 'tr'): 'turkish', + ('Ukrainian', 'ukr', 'uk'): 'ukrainian', + ('Vietnamese', 'vie', 'vi'): 'vietnamese', + } + + self.ultimate_lang_map = {} + for aliases_tuple, canonical_name in source_alias_map.items(): + if canonical_name in target_site_ids: + correct_id = target_site_ids[canonical_name] + for alias in aliases_tuple: + self.ultimate_lang_map[alias.lower()] = correct_id + + async def load_cookies(self, meta): + cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + if not os.path.exists(cookie_file): + console.print(f'[bold red]Arquivo de cookie para o {self.tracker} não encontrado: {cookie_file}[/bold red]') + return False + + self.session.cookies = await self.common.parseCookieFile(cookie_file) + + async def validate_credentials(self, meta): + await self.load_cookies(meta) + try: + upload_page_url = f'{self.base_url}/upload.php' + response = await self.session.get(upload_page_url, timeout=30.0) + response.raise_for_status() + + if 'login.php' in str(response.url): + console.print(f'[bold red]Falha na validação do {self.tracker}. O cookie parece estar expirado (redirecionado para login).[/bold red]') + return False + + auth_match = re.search(r'name="auth" value="([^"]+)"', response.text) + + if not auth_match: + console.print(f'[bold red]Falha na validação do {self.tracker}. Token auth não encontrado.[/bold red]') + console.print('[yellow]A estrutura do site pode ter mudado ou o login falhou silenciosamente.[/yellow]') + + failure_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(failure_path, 'w', encoding='utf-8') as f: + f.write(response.text) + console.print(f'[yellow]A resposta do servidor foi salva em {failure_path} para análise.[/yellow]') + return False + + return str(auth_match.group(1)) + + except httpx.TimeoutException: + console.print(f'[bold red]Erro no {self.tracker}: Timeout ao tentar validar credenciais.[/bold red]') + return False + except httpx.HTTPStatusError as e: + console.print(f'[bold red]Erro HTTP ao validar credenciais do {self.tracker}: Status {e.response.status_code}.[/bold red]') + return False + except httpx.RequestError as e: + console.print(f'[bold red]Erro de rede ao validar credenciais do {self.tracker}: {e.__class__.__name__}.[/bold red]') + return False + + def load_localized_data(self, meta): + localized_data_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/tmdb_localized_data.json" + + if os.path.isfile(localized_data_file): + with open(localized_data_file, "r", encoding="utf-8") as f: + self.tmdb_data = json.load(f) + else: + self.tmdb_data = {} + + async def ptbr_tmdb_data(self, meta): + brazil_data_in_meta = self.tmdb_data.get('pt-BR', {}).get('main') + if brazil_data_in_meta: + return brazil_data_in_meta + + data = await get_tmdb_localized_data(meta, data_type='main', language='pt-BR', append_to_response='credits,videos,content_ratings') + self.load_localized_data(meta) + + return data + + async def get_container(self, meta): + container = meta.get('container', '') + if container in ['avi', 'm2ts', 'm4v', 'mkv', 'mp4', 'ts', 'vob', 'wmv', 'mkv']: + return container.upper() + + return 'Outro' + + async def get_type(self, meta): + if meta.get('anime'): + return '5' + + category_map = { + 'TV': '1', + 'MOVIE': '0' + } + + return category_map.get(meta['category']) + + async def get_languages(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + lang_code = tmdb_data.get('original_language') + + if not lang_code: + return None + + try: + return langcodes.Language.make(lang_code).display_name('pt').capitalize() + + except LanguageTagError: + return lang_code + + async def get_audio(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + audio_languages = set(meta.get('audio_languages', [])) + + portuguese_languages = ['Portuguese', 'Português', 'pt'] + + has_pt_audio = any(lang in portuguese_languages for lang in audio_languages) + + original_lang = meta.get('original_language', '').lower() + is_original_pt = original_lang in portuguese_languages + + if has_pt_audio: + if is_original_pt: + return 'Nacional' + elif len(audio_languages) > 1: + return 'Dual Audio' + else: + return 'Dublado' + + return 'Legendado' + + async def get_subtitle(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + found_language_strings = meta.get('subtitle_languages', []) + + subtitle_ids = set() + for lang_str in found_language_strings: + target_id = self.ultimate_lang_map.get(lang_str.lower()) + if target_id: + subtitle_ids.add(target_id) + + has_pt_subtitles = 'Sim' if '49' in subtitle_ids else 'Nao' + + subtitle_ids = sorted(list(subtitle_ids)) + + if not subtitle_ids: + subtitle_ids.append('44') + + return has_pt_subtitles, subtitle_ids + + async def get_resolution(self, meta): + if meta.get('is_disc') == 'BDMV': + resolution_str = meta.get('resolution', '') + try: + height_num = int(resolution_str.lower().replace('p', '').replace('i', '')) + height = str(height_num) + + width_num = round((16 / 9) * height_num) + width = str(width_num) + except (ValueError, TypeError): + pass + + else: + video_mi = meta['mediainfo']['media']['track'][1] + width = video_mi['Width'] + height = video_mi['Height'] + + return width, height + + async def get_video_codec(self, meta): + video_encode = meta.get('video_encode', '').strip().lower() + codec_final = meta.get('video_codec', '') + is_hdr = bool(meta.get('hdr')) + + encode_map = { + 'x265': 'x265', + 'h.265': 'H.265', + 'x264': 'x264', + 'h.264': 'H.264', + 'vp9': 'VP9', + 'xvid': 'XviD', + } + + for key, value in encode_map.items(): + if key in video_encode: + if value in ['x265', 'H.265'] and is_hdr: + return f'{value} HDR' + return value + + codec_lower = codec_final.lower() + + codec_map = { + 'hevc': 'x265', + 'avc': 'x264', + 'mpeg-2': 'MPEG-2', + 'vc-1': 'VC-1', + } + + for key, value in codec_map.items(): + if key in codec_lower: + return f"{value} HDR" if value == "x265" and is_hdr else value + + return codec_final if codec_final else "Outro" + + async def get_audio_codec(self, meta): + priority_order = [ + 'DTS-X', 'E-AC-3 JOC', 'TrueHD', 'DTS-HD', 'PCM', 'FLAC', 'DTS-ES', + 'DTS', 'E-AC-3', 'AC3', 'AAC', 'Opus', 'Vorbis', 'MP3', 'MP2' + ] + + codec_map = { + 'DTS-X': ['DTS:X'], + 'E-AC-3 JOC': ['DD+ 5.1 Atmos', 'DD+ 7.1 Atmos'], + 'TrueHD': ['TrueHD'], + 'DTS-HD': ['DTS-HD'], + 'PCM': ['LPCM'], + 'FLAC': ['FLAC'], + 'DTS-ES': ['DTS-ES'], + 'DTS': ['DTS'], + 'E-AC-3': ['DD+'], + 'AC3': ['DD'], + 'AAC': ['AAC'], + 'Opus': ['Opus'], + 'Vorbis': ['VORBIS'], + 'MP2': ['MP2'], + 'MP3': ['MP3'] + } + + audio_description = meta.get('audio') + + if not audio_description or not isinstance(audio_description, str): + return 'Outro' + + for codec_name in priority_order: + search_terms = codec_map.get(codec_name, []) + + for term in search_terms: + if term in audio_description: + return codec_name + + return 'Outro' + + async def get_title(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + + title = tmdb_data.get('name') or tmdb_data.get('title') or '' + + return title if title and title != meta.get('title') else '' + + async def get_description(self, meta): + description = [] + + base_desc = '' + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as f: + base_desc = f.read() + if base_desc: + description.append(base_desc) + + custom_description_header = self.config['DEFAULT'].get('custom_description_header', '') + if custom_description_header: + description.append(custom_description_header + '\n') + + if self.signature: + description.append(self.signature) + + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + with open(final_desc_path, 'w', encoding='utf-8') as descfile: + final_description = '\n'.join(filter(None, description)) + descfile.write(final_description) + + return final_description + + async def get_trailer(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + video_results = tmdb_data.get('videos', {}).get('results', []) + + youtube = '' + + if video_results: + youtube = video_results[-1].get('key', '') + + if not youtube: + meta_trailer = meta.get('youtube', '') + if meta_trailer: + youtube = meta_trailer.replace('https://www.youtube.com/watch?v=', '').replace('/', '') + + return youtube + + async def get_tags(self, meta): + tmdb_data = await self.ptbr_tmdb_data(meta) + tags = '' + + if tmdb_data and isinstance(tmdb_data.get('genres'), list): + genre_names = [ + g.get('name', '') for g in tmdb_data['genres'] + if isinstance(g.get('name'), str) and g.get('name').strip() + ] + + if genre_names: + tags = ', '.join( + unicodedata.normalize('NFKD', name) + .encode('ASCII', 'ignore') + .decode('utf-8') + .replace(' ', '.') + .lower() + for name in genre_names + ) + + if not tags: + tags = await asyncio.to_thread(input, f'Digite os gêneros (no formato do {self.tracker}): ') + + return tags + + async def search_existing(self, meta, disctype): + is_tv_pack = bool(meta.get('tv_pack')) + + search_url = f"{self.base_url}/torrents.php?searchstr={meta['imdb_info']['imdbID']}" + + found_items = [] + try: + response = await self.session.get(search_url) + response.raise_for_status() + soup = BeautifulSoup(response.text, 'html.parser') + + torrent_table = soup.find('table', id='torrent_table') + if not torrent_table: + return [] + + group_links = set() + for group_row in torrent_table.find_all('tr'): + link = group_row.find('a', href=re.compile(r'torrents\.php\?id=\d+')) + if link and 'torrentid' not in link.get('href', ''): + group_links.add(link['href']) + + if not group_links: + return [] + + for group_link in group_links: + group_url = f'{self.base_url}/{group_link}' + group_response = await self.session.get(group_url) + group_response.raise_for_status() + group_soup = BeautifulSoup(group_response.text, 'html.parser') + + for torrent_row in group_soup.find_all('tr', id=re.compile(r'^torrent\d+$')): + desc_link = torrent_row.find('a', onclick=re.compile(r'gtoggle')) + if not desc_link: + continue + description_text = ' '.join(desc_link.get_text(strip=True).split()) + + torrent_id = torrent_row.get('id', '').replace('torrent', '') + file_div = group_soup.find('div', id=f'files_{torrent_id}') + if not file_div: + continue + + is_existing_torrent_a_disc = any(keyword in description_text.lower() for keyword in ['bd25', 'bd50', 'bd66', 'bd100', 'dvd5', 'dvd9', 'm2ts']) + + if is_existing_torrent_a_disc or is_tv_pack: + path_div = file_div.find('div', class_='filelist_path') + if path_div: + folder_name = path_div.get_text(strip=True).strip('/') + if folder_name: + found_items.append(folder_name) + else: + file_table = file_div.find('table', class_='filelist_table') + if file_table: + for row in file_table.find_all('tr'): + if 'colhead_dark' not in row.get('class', []): + cell = row.find('td') + if cell: + filename = cell.get_text(strip=True) + if filename: + found_items.append(filename) + break + + except Exception as e: + console.print(f'[bold red]Ocorreu um erro inesperado ao processar a busca: {e}[/bold red]') + return [] + + return found_items + + async def get_media_info(self, meta): + info_file_path = '' + if meta.get('is_disc') == 'BDMV': + info_file_path = f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/BD_SUMMARY_00.txt" + else: + info_file_path = f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/MEDIAINFO_CLEANPATH.txt" + + if os.path.exists(info_file_path): + try: + with open(info_file_path, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + console.print(f'[bold red]Erro ao ler o arquivo de info em {info_file_path}: {e}[/bold red]') + return '' + else: + console.print(f'[bold red]Arquivo de info não encontrado: {info_file_path}[/bold red]') + return '' + + async def get_edition(self, meta): + edition_str = meta.get('edition', '').lower() + if not edition_str: + return '' + + edition_map = { + "director's cut": "Director's Cut", + 'theatrical': 'Theatrical Cut', + 'extended': 'Extended', + 'uncut': 'Uncut', + 'unrated': 'Unrated', + 'imax': 'IMAX', + 'noir': 'Noir', + 'remastered': 'Remastered', + } + + for keyword, label in edition_map.items(): + if keyword in edition_str: + return label + + return '' + + async def get_bitrate(self, meta): + if meta.get('type') == 'DISC': + is_disc_type = meta.get('is_disc') + + if is_disc_type == 'BDMV': + disctype = meta.get('disctype') + if disctype in ['BD100', 'BD66', 'BD50', 'BD25']: + return disctype + + try: + size_in_gb = meta['bdinfo']['size'] + except (KeyError, IndexError, TypeError): + size_in_gb = 0 + + if size_in_gb > 66: + return 'BD100' + elif size_in_gb > 50: + return 'BD66' + elif size_in_gb > 25: + return 'BD50' + else: + return 'BD25' + + elif is_disc_type == 'DVD': + dvd_size = meta.get('dvd_size') + if dvd_size in ['DVD9', 'DVD5']: + return dvd_size + return 'DVD9' + + source_type = meta.get('type') + + if not source_type or not isinstance(source_type, str): + return 'Outro' + + keyword_map = { + 'remux': 'Remux', + 'webdl': 'WEB-DL', + 'webrip': 'WEBRip', + 'web': 'WEB', + 'encode': 'Blu-ray', + 'bdrip': 'BDRip', + 'brrip': 'BRRip', + 'hdtv': 'HDTV', + 'sdtv': 'SDTV', + 'dvdrip': 'DVDRip', + 'hd-dvd': 'HD-DVD', + 'tvrip': 'TVRip', + } + + return keyword_map.get(source_type.lower(), 'Outro') + + async def get_screens(self, meta): + screenshot_urls = [ + image.get('raw_url') + for image in meta.get('image_list', []) + if image.get('raw_url') + ] + + return screenshot_urls + + async def get_credits(self, meta): + director = (meta.get('imdb_info', {}).get('directors') or []) + (meta.get('tmdb_directors') or []) + if director: + unique_names = list(dict.fromkeys(director))[:5] + return ', '.join(unique_names) + else: + return 'N/A' + + async def get_data(self, meta, disctype): + await self.load_cookies(meta) + self.load_localized_data(meta) + tmdb_data = await self.ptbr_tmdb_data(meta) + has_pt_subtitles, subtitle_ids = await self.get_subtitle(meta) + resolution_width, resolution_height = await self.get_resolution(meta) + + data = { + 'audio_c': await self.get_audio_codec(meta), + 'audio': await self.get_audio(meta), + 'auth': meta[f'{self.tracker}_secret_token'], + 'bitrate': await self.get_bitrate(meta), + 'desc': '', + 'diretor': await self.get_credits(meta), + 'duracao': f"{str(meta.get('runtime', ''))} min", + 'especificas': await self.get_description(meta), + 'format': await self.get_container(meta), + 'idioma_ori': await self.get_languages(meta) or meta.get('original_language', ''), + 'image': f"https://image.tmdb.org/t/p/w500{tmdb_data.get('poster_path') or meta.get('tmdb_poster', '')}", + 'legenda': has_pt_subtitles, + 'mediainfo': await self.get_media_info(meta), + 'resolucao_1': resolution_width, + 'resolucao_2': resolution_height, + 'screen[]': await self.get_screens(meta), + 'sinopse': tmdb_data.get('overview', 'Nenhuma sinopse disponível.'), + 'submit': 'true', + 'subtitles[]': subtitle_ids, + 'tags': await self.get_tags(meta), + 'title': meta['title'], + 'type': await self.get_type(meta), + 'video_c': await self.get_video_codec(meta), + 'year': str(meta['year']), + 'youtube': await self.get_trailer(meta), + } + + # Common data MOVIE/TV + if not meta.get('anime'): + if meta['category'] in ('MOVIE', 'TV'): + data.update({ + '3d': 'Sim' if meta.get('3d') else 'Nao', + 'adulto': '0', + 'imdb_input': meta.get('imdb_info', {}).get('imdbID', ''), + 'nota_imdb': str(meta.get('imdb_info', {}).get('rating', '')), + 'title_br': await self.get_title(meta), + }) + + # Common data TV/Anime + tv_pack = bool(meta.get('tv_pack')) + if meta['category'] == 'TV' or meta.get('anime'): + data.update({ + 'episodio': meta.get('episode', ''), + 'ntorrent': f"{meta.get('season', '')}{meta.get('episode', '')}", + 'temporada_e': meta.get('season', '') if not tv_pack else '', + 'temporada': meta.get('season', '') if tv_pack else '', + 'tipo': 'ep_individual' if not tv_pack else 'completa', + }) + + # Specific + if meta['category'] == 'MOVIE': + data['versao'] = await self.get_edition(meta) + elif meta.get('anime'): + data.update({ + 'fundo_torrent': meta.get('backdrop'), + 'horas': '', + 'minutos': '', + 'rating': str(meta.get('imdb_info', {}).get('rating', '')), + 'releasedate': str(meta['year']), + 'vote': '', + }) + + # Anon + anon = not (meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False)) + if anon: + data['anonymous'] = '1' + + return data + + async def upload(self, meta, disctype): + await self.common.edit_torrent(meta, self.tracker, self.source_flag) + data = await self.get_data(meta, disctype) + status_message = '' + + if not meta.get('debug', False): + torrent_id = '' + upload_url = f'{self.base_url}/upload.php' + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = {'file_input': (f'{self.tracker}.placeholder.torrent', torrent_file, 'application/x-bittorrent')} + + response = await self.session.post(upload_url, data=data, files=files, timeout=120) + + if response.status_code in (200, 302, 303): + status_message = 'Enviado com sucesso.' + + match = re.search(r'id=(\d+)', response.headers['Location']) + if match: + torrent_id = match.group(1) + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + else: + response_save_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(response_save_path, 'w', encoding='utf-8') as f: + f.write(response.text) + status_message = f'data error - O upload pode ter falhado, a resposta HTML foi salva em: {response_save_path}' + return + + await self.common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce, self.torrent_url + torrent_id) + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading.' + + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/CBR.py b/src/trackers/CBR.py new file mode 100644 index 000000000..2edc684e0 --- /dev/null +++ b/src/trackers/CBR.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D +from src.console import console +from src.languages import process_desc_language +import re + + +class CBR(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='CBR') + self.config = config + self.common = COMMON(config) + self.tracker = 'CBR' + self.source_flag = 'CapybaraBR' + self.base_url = 'https://capybarabr.com' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + '3LTON', '4yEo', 'ADE', 'AFG', 'AROMA', 'AniHLS', 'AniURL', 'AnimeRG', 'BLUDV', 'CHD', 'CM8', 'Comando', 'CrEwSaDe', 'DNL', 'DeadFish', + 'ELiTE', 'FGT', 'FRDS', 'FUM', 'FaNGDiNG0', 'Flights', 'HAiKU', 'HD2DVD', 'HDS', 'HDTime', 'Hi10', 'Hiro360', 'ION10', 'JIVE', 'KiNGDOM', + 'LEGi0N', 'LOAD', 'Lapumia', 'Leffe', 'MACCAULAY', 'MeGusta', 'NOIVTC', 'NhaNc3', 'OFT', 'Oj', 'PRODJi', 'PiRaTeS', 'PlaySD', 'RAPiDCOWS', + 'RARBG', 'RDN', 'REsuRRecTioN', 'RMTeam', 'RetroPeeps', 'SANTi', 'SILVEIRATeam', 'SPASM', 'SPDVD', 'STUTTERSHIT', 'SicFoI', 'TGx', 'TM', + 'TRiToN', 'Telly', 'UPiNSMOKE', 'URANiME', 'WAF', 'XS', 'YIFY', 'ZKBL', 'ZMNT', 'ZmN', 'aXXo', 'd3g', 'eSc', 'iPlanet', 'mHD', 'mSD', 'nHD', + 'nSD', 'nikt0', 'playXD', 'x0r', 'xRed' + ] + pass + + async def get_category_id(self, meta): + category_id = { + 'MOVIE': '1', + 'TV': '2', + 'ANIMES': '4' + }.get(meta['category'], '0') + if meta['anime'] is True and category_id == '2': + category_id = '4' + return {'category_id': category_id} + + async def get_type_id(self, meta): + type_id = { + 'DISC': '1', + 'REMUX': '2', + 'ENCODE': '3', + 'DVDRIP': '3', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6' + }.get(meta['type'], '0') + return {'type_id': type_id} + + async def get_resolution_id(self, meta): + resolution_id = { + '4320p': '1', + '2160p': '2', + '1080p': '3', + '1080i': '4', + '720p': '5', + '576p': '6', + '576i': '7', + '480p': '8', + '480i': '9', + 'Other': '10', + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} + + async def get_name(self, meta): + name = meta['name'].replace('DD+ ', 'DDP').replace('DD ', 'DD').replace('AAC ', 'AAC').replace('FLAC ', 'FLAC') + + # If it is a Series or Anime, remove the year from the title. + if meta.get('category') in ['TV', 'ANIMES']: + year = str(meta.get('year', '')) + if year and year in name: + name = name.replace(year, '').replace(f"({year})", '').strip() + name = re.sub(r'\s{2,}', ' ', name) + + # Remove the AKA title, unless it is Brazilian + if meta.get('original_language') != 'pt': + name = name.replace(meta["aka"], '') + + # If it is Brazilian, use only the AKA title, deleting the foreign title + if meta.get('original_language') == 'pt' and meta.get('aka'): + aka_clean = meta['aka'].replace('AKA', '').strip() + title = meta.get('title') + name = name.replace(meta["aka"], '').replace(title, aka_clean).strip() + + cbr_name = name + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + + if meta.get('no_dual', False): + if meta.get('dual_audio', False): + cbr_name = cbr_name.replace("Dual-Audio ", '') + else: + if meta.get('audio_languages') and not meta.get('is_disc') == "BDMV": + audio_languages = set(meta['audio_languages']) + if len(audio_languages) >= 3: + audio_tag = ' MULTI' + elif len(audio_languages) == 2: + audio_tag = ' DUAL' + else: + audio_tag = '' + + if audio_tag: + if meta.get('dual_audio', False): + cbr_name = cbr_name.replace("Dual-Audio ", '') + if '-' in cbr_name: + parts = cbr_name.rsplit('-', 1) + cbr_name = f"{parts[0]}{audio_tag}-{parts[1]}" + else: + cbr_name += audio_tag + + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + cbr_name = re.sub(f"-{invalid_tag}", "", cbr_name, flags=re.IGNORECASE) + cbr_name = f"{cbr_name}-NoGroup" + + return {'name': cbr_name} + + async def get_additional_data(self, meta): + data = { + 'mod_queue_opt_in': await self.get_flag(meta, 'modq'), + } + + return data + + async def get_additional_checks(self, meta): + should_continue = True + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + portuguese_languages = ['Portuguese', 'Português'] + if not any(lang in meta.get('audio_languages', []) for lang in portuguese_languages) and not any(lang in meta.get('subtitle_languages', []) for lang in portuguese_languages): + console.print('[bold red]CBR requires at least one Portuguese audio or subtitle track.') + should_continue = False + + return should_continue diff --git a/src/trackers/COMMON.py b/src/trackers/COMMON.py index 87fc0ccfb..c78bea5af 100644 --- a/src/trackers/COMMON.py +++ b/src/trackers/COMMON.py @@ -1,183 +1,1103 @@ -from torf import Torrent +import aiofiles +import asyncio +import click +import glob +import httpx +import json import os -import traceback -import requests import re -import json - +import requests +import secrets +import sys +from pymediainfo import MediaInfo from src.bbcode import BBCODE from src.console import console +from src.exportmi import exportInfo +from src.languages import process_desc_language +from src.takescreens import disc_screenshots, dvd_screenshots, screenshots +from src.uploadscreens import upload_screens +from torf import Torrent + class COMMON(): def __init__(self, config): self.config = config + self.parser = self.MediaInfoParser() pass - async def edit_torrent(self, meta, tracker, source_flag, torrent_filename="BASE"): - if os.path.exists(f"{meta['base_dir']}/tmp/{meta['uuid']}/{torrent_filename}.torrent"): - new_torrent = Torrent.read(f"{meta['base_dir']}/tmp/{meta['uuid']}/{torrent_filename}.torrent") + async def path_exists(self, path): + """Async wrapper for os.path.exists""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, os.path.exists, path) + + async def remove_file(self, path): + """Async wrapper for os.remove""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, os.remove, path) + + async def makedirs(self, path, exist_ok=True): + """Async wrapper for os.makedirs""" + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, lambda p, e: os.makedirs(p, exist_ok=e), path, exist_ok) + + async def edit_torrent(self, meta, tracker, source_flag, torrent_filename="BASE", announce_url=None): + path = f"{meta['base_dir']}/tmp/{meta['uuid']}/{torrent_filename}.torrent" + if await self.path_exists(path): + loop = asyncio.get_running_loop() + new_torrent = await loop.run_in_executor(None, Torrent.read, path) for each in list(new_torrent.metainfo): if each not in ('announce', 'comment', 'creation date', 'created by', 'encoding', 'info'): new_torrent.metainfo.pop(each, None) - new_torrent.metainfo['announce'] = self.config['TRACKERS'][tracker].get('announce_url', "https://fake.tracker").strip() + new_torrent.metainfo['announce'] = announce_url if announce_url else self.config['TRACKERS'][tracker].get('announce_url', "https://fake.tracker").strip() new_torrent.metainfo['info']['source'] = source_flag - Torrent.copy(new_torrent).write(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}]{meta['clean_name']}.torrent", overwrite=True) + if 'created by' in new_torrent.metainfo and isinstance(new_torrent.metainfo['created by'], str): + created_by = new_torrent.metainfo['created by'] + if "mkbrr" in created_by.lower(): + new_torrent.metainfo['created by'] = f"{created_by} using Upload Assistant" + # setting comment as blank as if BASE.torrent is manually created then it can result in private info such as download link being exposed. + new_torrent.metainfo['comment'] = '' + if int(meta.get('entropy', None)) == 32: + new_torrent.metainfo['info']['entropy'] = secrets.randbelow(2**31) + elif int(meta.get('entropy', None)) == 64: + new_torrent.metainfo['info']['entropy'] = secrets.randbelow(2**64) + out_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}].torrent" + await loop.run_in_executor(None, lambda: Torrent.copy(new_torrent).write(out_path, overwrite=True)) # used to add tracker url, comment and source flag to torrent file - async def add_tracker_torrent(self, meta, tracker, source_flag, new_tracker, comment): - if os.path.exists(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent"): - new_torrent = Torrent.read(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent") - new_torrent.metainfo['announce'] = new_tracker + async def add_tracker_torrent(self, meta, tracker, source_flag, new_tracker, comment, headers=None, params=None, downurl=None): + path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}].torrent" + if downurl is not None: + session = httpx.AsyncClient(headers=headers, params=params, timeout=30.0) + try: + async with session.stream("GET", downurl) as r: + r.raise_for_status() + with open(path, "wb") as f: + async for chunk in r.aiter_bytes(): + f.write(chunk) + return + except Exception as e: + console.print(f"[yellow]Warning: Could not download torrent file: {str(e)}[/yellow]") + console.print("[yellow]Download manually from the tracker.[/yellow]") + return + + if await self.path_exists(path): + loop = asyncio.get_running_loop() + new_torrent = await loop.run_in_executor(None, Torrent.read, path) + if isinstance(new_tracker, list): + new_torrent.metainfo['announce'] = new_tracker[0] + new_torrent.metainfo['announce-list'] = [new_tracker] + else: + new_torrent.metainfo['announce'] = new_tracker new_torrent.metainfo['comment'] = comment new_torrent.metainfo['info']['source'] = source_flag - Torrent.copy(new_torrent).write(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}]{meta['clean_name']}.torrent", overwrite=True) - - - async def unit3d_edit_desc(self, meta, tracker, signature, comparison=False, desc_header=""): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf8').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}]DESCRIPTION.txt", 'w', encoding='utf8') as descfile: - if desc_header != "": - descfile.write(desc_header) - + await loop.run_in_executor(None, lambda: Torrent.copy(new_torrent).write(path, overwrite=True)) + + async def unit3d_edit_desc(self, meta, tracker, signature, comparison=False, desc_header="", image_list=None): + if image_list is not None: + images = image_list + multi_screens = 0 + else: + images = meta['image_list'] + multi_screens = int(self.config['DEFAULT'].get('multiScreens', 2)) + if meta.get('sorted_filelist'): + multi_screens = 0 + + # Check for saved pack_image_links.json file + pack_images_file = os.path.join(meta['base_dir'], "tmp", meta['uuid'], "pack_image_links.json") + pack_images_data = {} + if await self.path_exists(pack_images_file): + try: + async with aiofiles.open(pack_images_file, 'r', encoding='utf-8') as f: + pack_images_data = json.loads(await f.read()) + if meta['debug']: + console.print(f"[green]Loaded previously uploaded images from {pack_images_file}") + console.print(f"[blue]Found {pack_images_data.get('total_count', 0)} previously uploaded images") + except Exception as e: + console.print(f"[yellow]Warning: Could not load pack image data: {str(e)}[/yellow]") + + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf8') as f: + base = await f.read() + char_limit = int(self.config['DEFAULT'].get('charLimit', 14000)) + file_limit = int(self.config['DEFAULT'].get('fileLimit', 5)) + thumb_size = int(self.config['DEFAULT'].get('pack_thumb_size', '300')) + cover_size = int(self.config['DEFAULT'].get('bluray_image_size', '250')) + process_limit = int(self.config['DEFAULT'].get('processLimit', 10)) + episode_overview = int(self.config['DEFAULT'].get('episode_overview', False)) + try: + # If tracker has screenshot header specified in config, use that. Otherwise, check if screenshot default is used. Otherwise, fall back to None + screenheader = self.config['TRACKERS'][tracker].get('custom_screenshot_header', self.config['DEFAULT'].get('screenshot_header', None)) + except Exception: + screenheader = None + try: + # If tracker has description header specified in config, use that. Otherwise, check if custom description header default is used. + desc_header = self.config['TRACKERS'][tracker].get('custom_description_header', self.config['DEFAULT'].get('custom_description_header', desc_header)) + except Exception as e: + console.print(f"[yellow]Warning: Error setting custom description header: {str(e)}[/yellow]") + try: + # If screensPerRow is set, use that to determine how many screenshots should be on each row. Otherwise, use 2 as default + screensPerRow = int(self.config['DEFAULT'].get('screens_per_row', 2)) + except Exception: + screensPerRow = 2 + try: + # If custom signature set and isn't empty, use that instead of the signature parameter + custom_signature = self.config['TRACKERS'][tracker].get('custom_signature', signature) + if custom_signature != '': + signature = custom_signature + except Exception as e: + console.print(f"[yellow]Warning: Error setting custom signature: {str(e)}[/yellow]") + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}]DESCRIPTION.txt", 'w', encoding='utf8') as descfile: + if desc_header: + if not desc_header.endswith('\n'): + await descfile.write(desc_header + '\n') + else: + await descfile.write(desc_header) + + if not meta.get('language_checked', False): + await process_desc_language(meta, descfile, tracker) + if meta['audio_languages'] and meta['write_audio_languages']: + await descfile.write(f"[code]Audio Language/s: {', '.join(meta['audio_languages'])}[/code]\n") + + if meta['subtitle_languages'] and meta['write_subtitle_languages']: + await descfile.write(f"[code]Subtitle Language/s: {', '.join(meta['subtitle_languages'])}[/code]\n") + if meta['subtitle_languages'] and meta['write_hc_languages']: + await descfile.write(f"[code]Hardcoded Subtitle Language/s: {', '.join(meta['subtitle_languages'])}[/code]\n") + + add_logo_enabled = self.config["DEFAULT"].get("add_logo", False) + if add_logo_enabled and 'logo' in meta: + logo = meta['logo'] + logo_size = self.config["DEFAULT"].get("logo_size", 420) + if logo != "": + await descfile.write(f"[center][img={logo_size}]{logo}[/img][/center]\n\n") + bluray_link = self.config['DEFAULT'].get("add_bluray_link", False) + if meta.get('is_disc') in ["BDMV", "DVD"] and bluray_link and meta.get('release_url', ''): + await descfile.write(f"[center]{meta['release_url']}[/center]\n") + covers = False + if await self.path_exists(f"{meta['base_dir']}/tmp/{meta['uuid']}/covers.json"): + covers = True + + if meta.get('is_disc') in ["BDMV", "DVD"] and self.config['DEFAULT'].get('use_bluray_images', False) and covers: + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/covers.json", 'r', encoding='utf-8') as f: + cover_data = json.loads(await f.read()) + + if isinstance(cover_data, list): + await descfile.write("[center]") + + for img_data in cover_data: + if 'raw_url' in img_data and 'web_url' in img_data: + web_url = img_data['web_url'] + raw_url = img_data['raw_url'] + await descfile.write(f"[url={web_url}][img={cover_size}]{raw_url}[/img][/url]") + + await descfile.write("[/center]\n\n") + + season_name = meta.get('tvdb_season_name') if meta.get('tvdb_season_name') is not None and meta.get('tvdb_season_name') != "" else None + season_number = meta.get('tvdb_season_number') if meta.get('tvdb_season_number') is not None and meta.get('tvdb_season_number') != "" else None + episode_number = meta.get('tvdb_episode_number') if meta.get('tvdb_episode_number') is not None and meta.get('tvdb_episode_number') != "" else None + episode_title = meta.get('auto_episode_title') if meta.get('auto_episode_title') is not None and meta.get('auto_episode_title') != "" else None + if episode_title is None: + episode_title = meta.get('tvmaze_episode_data', {}).get('episode_name') if meta.get('tvmaze_episode_data', {}).get('episode_name') else None + if episode_overview and season_name and season_number and episode_number and episode_title: + if not tracker == "HUNO": + await descfile.write("[center][pre]") + else: + await descfile.write("[center]") + await descfile.write(f"{season_name} - S{season_number}E{episode_number}: {episode_title}") + if not tracker == "HUNO": + await descfile.write("[/pre][/center]\n\n") + else: + await descfile.write("[/center]\n\n") + if episode_overview and meta.get('overview_meta') is not None and meta.get('overview_meta') != "": + episode_data = meta.get('overview_meta') + if not tracker == "HUNO": + await descfile.write("[center][pre]") + else: + await descfile.write("[center]") + await descfile.write(episode_data) + if not tracker == "HUNO": + await descfile.write("[/pre][/center]\n\n") + else: + await descfile.write("[/center]\n\n") + + try: + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + await descfile.write(self.config['DEFAULT'].get('tonemapped_header')) + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + bbcode = BBCODE() - if meta.get('discs', []) != []: - discs = meta['discs'] - if discs[0]['type'] == "DVD": - descfile.write(f"[spoiler=VOB MediaInfo][code]{discs[0]['vob_mi']}[/code][/spoiler]\n") - descfile.write("\n") - if len(discs) >= 2: - for each in discs[1:]: - if each['type'] == "BDMV": - descfile.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]\n") - descfile.write("\n") - elif each['type'] == "DVD": - descfile.write(f"{each['name']}:\n") - descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code][{each['vob_mi']}[/code][/spoiler] [spoiler={os.path.basename(each['ifo'])}][code][{each['ifo_mi']}[/code][/spoiler]\n") - descfile.write("\n") - elif each['type'] == "HDDVD": - descfile.write(f"{each['name']}:\n") - descfile.write(f"[spoiler={os.path.basename(each['largest_evo'])}][code][{each['evo_mi']}[/code][/spoiler]\n") - descfile.write("\n") + discs = meta.get('discs', []) + filelist = meta.get('filelist', []) desc = base + desc = re.sub(r'\[center\]\[spoiler=Scene NFO:\].*?\[/center\]', '', desc, flags=re.DOTALL) + if not tracker == "AITHER": + desc = re.sub(r'\[center\]\[spoiler=FraMeSToR NFO:\].*?\[/center\]', '', desc, flags=re.DOTALL) + else: + if "framestor" in meta and meta['framestor']: + desc = re.sub(r'\[center\]\[spoiler=FraMeSToR NFO:\]', '', desc, count=1) + desc = re.sub(r'\[/spoiler\]\[/center\]', '', desc, count=1) + desc = desc.replace("https://i.imgur.com/e9o0zpQ.png", "https://beyondhd.co/images/2017/11/30/c5802892418ee2046efba17166f0cad9.png") + images = [] desc = bbcode.convert_pre_to_code(desc) desc = bbcode.convert_hide_to_spoiler(desc) - if comparison == False: + desc = desc.replace("[user]", "").replace("[/user]", "") + desc = desc.replace("[hr]", "").replace("[/hr]", "") + desc = desc.replace("[ul]", "").replace("[/ul]", "") + desc = desc.replace("[ol]", "").replace("[/ol]", "") + if comparison is False: desc = bbcode.convert_comparison_to_collapse(desc, 1000) - desc = desc.replace('[img]', '[img=300]') - descfile.write(desc) - images = meta['image_list'] - if len(images) > 0: - descfile.write("[center]") - for each in range(len(images[:int(meta['screens'])])): - web_url = images[each]['web_url'] - raw_url = images[each]['raw_url'] - descfile.write(f"[url={web_url}][img=350]{raw_url}[/img][/url]") - descfile.write("[/center]") - - if signature != None: - descfile.write(signature) - descfile.close() - return - - - - - async def unit3d_region_ids(self, region): - region_id = { - 'AFG': 1, 'AIA': 2, 'ALA': 3, 'ALG': 4, 'AND': 5, 'ANG': 6, 'ARG': 7, 'ARM': 8, 'ARU': 9, - 'ASA': 10, 'ATA': 11, 'ATF': 12, 'ATG': 13, 'AUS': 14, 'AUT': 15, 'AZE': 16, 'BAH': 17, - 'BAN': 18, 'BDI': 19, 'BEL': 20, 'BEN': 21, 'BER': 22, 'BES': 23, 'BFA': 24, 'BHR': 25, - 'BHU': 26, 'BIH': 27, 'BLM': 28, 'BLR': 29, 'BLZ': 30, 'BOL': 31, 'BOT': 32, 'BRA': 33, - 'BRB': 34, 'BRU': 35, 'BVT': 36, 'CAM': 37, 'CAN': 38, 'CAY': 39, 'CCK': 40, 'CEE': 41, - 'CGO': 42, 'CHA': 43, 'CHI': 44, 'CHN': 45, 'CIV': 46, 'CMR': 47, 'COD': 48, 'COK': 49, - 'COL': 50, 'COM': 51, 'CPV': 52, 'CRC': 53, 'CRO': 54, 'CTA': 55, 'CUB': 56, 'CUW': 57, - 'CXR': 58, 'CYP': 59, 'DJI': 60, 'DMA': 61, 'DOM': 62, 'ECU': 63, 'EGY': 64, 'ENG': 65, - 'EQG': 66, 'ERI': 67, 'ESH': 68, 'ESP': 69, 'ETH': 70, 'FIJ': 71, 'FLK': 72, 'FRA': 73, - 'FRO': 74, 'FSM': 75, 'GAB': 76, 'GAM': 77, 'GBR': 78, 'GEO': 79, 'GER': 80, 'GGY': 81, - 'GHA': 82, 'GIB': 83, 'GLP': 84, 'GNB': 85, 'GRE': 86, 'GRL': 87, 'GRN': 88, 'GUA': 89, - 'GUF': 90, 'GUI': 91, 'GUM': 92, 'GUY': 93, 'HAI': 94, 'HKG': 95, 'HMD': 96, 'HON': 97, - 'HUN': 98, 'IDN': 99, 'IMN': 100, 'IND': 101, 'IOT': 102, 'IRL': 103, 'IRN': 104, 'IRQ': 105, - 'ISL': 106, 'ISR': 107, 'ITA': 108, 'JAM': 109, 'JEY': 110, 'JOR': 111, 'JPN': 112, 'KAZ': 113, - 'KEN': 114, 'KGZ': 115, 'KIR': 116, 'KNA': 117, 'KOR': 118, 'KSA': 119, 'KUW': 120, 'KVX': 121, - 'LAO': 122, 'LBN': 123, 'LBR': 124, 'LBY': 125, 'LCA': 126, 'LES': 127, 'LIE': 128, 'LKA': 129, - 'LUX': 130, 'MAC': 131, 'MAD': 132, 'MAF': 133, 'MAR': 134, 'MAS': 135, 'MDA': 136, 'MDV': 137, - 'MEX': 138, 'MHL': 139, 'MKD': 140, 'MLI': 141, 'MLT': 142, 'MNG': 143, 'MNP': 144, 'MON': 145, - 'MOZ': 146, 'MRI': 147, 'MSR': 148, 'MTN': 149, 'MTQ': 150, 'MWI': 151, 'MYA': 152, 'MYT': 153, - 'NAM': 154, 'NCA': 155, 'NCL': 156, 'NEP': 157, 'NFK': 158, 'NIG': 159, 'NIR': 160, 'NIU': 161, - 'NLD': 162, 'NOR': 163, 'NRU': 164, 'NZL': 165, 'OMA': 166, 'PAK': 167, 'PAN': 168, 'PAR': 169, - 'PCN': 170, 'PER': 171, 'PHI': 172, 'PLE': 173, 'PLW': 174, 'PNG': 175, 'POL': 176, 'POR': 177, - 'PRK': 178, 'PUR': 179, 'QAT': 180, 'REU': 181, 'ROU': 182, 'RSA': 183, 'RUS': 184, 'RWA': 185, - 'SAM': 186, 'SCO': 187, 'SDN': 188, 'SEN': 189, 'SEY': 190, 'SGS': 191, 'SHN': 192, 'SIN': 193, - 'SJM': 194, 'SLE': 195, 'SLV': 196, 'SMR': 197, 'SOL': 198, 'SOM': 199, 'SPM': 200, 'SRB': 201, - 'SSD': 202, 'STP': 203, 'SUI': 204, 'SUR': 205, 'SWZ': 206, 'SXM': 207, 'SYR': 208, 'TAH': 209, - 'TAN': 210, 'TCA': 211, 'TGA': 212, 'THA': 213, 'TJK': 214, 'TKL': 215, 'TKM': 216, 'TLS': 217, - 'TOG': 218, 'TRI': 219, 'TUN': 220, 'TUR': 221, 'TUV': 222, 'TWN': 223, 'UAE': 224, 'UGA': 225, - 'UKR': 226, 'UMI': 227, 'URU': 228, 'USA': 229, 'UZB': 230, 'VAN': 231, 'VAT': 232, 'VEN': 233, - 'VGB': 234, 'VIE': 235, 'VIN': 236, 'VIR': 237, 'WAL': 238, 'WLF': 239, 'YEM': 240, 'ZAM': 241, - 'ZIM': 242, 'EUR' : 243 - }.get(region, 0) - return region_id - - async def unit3d_distributor_ids(self, distributor): - distributor_id = { - '01 DISTRIBUTION': 1, '100 DESTINATIONS TRAVEL FILM': 2, '101 FILMS': 3, '1FILMS': 4, '2 ENTERTAIN VIDEO': 5, '20TH CENTURY FOX': 6, '2L': 7, '3D CONTENT HUB': 8, '3D MEDIA': 9, '3L FILM': 10, '4DIGITAL': 11, '4DVD': 12, '4K ULTRA HD MOVIES': 13, '4K UHD': 13, '8-FILMS': 14, '84 ENTERTAINMENT': 15, '88 FILMS': 16, '@ANIME': 17, 'ANIME': 17, 'A CONTRACORRIENTE': 18, 'A CONTRACORRIENTE FILMS': 19, 'A&E HOME VIDEO': 20, 'A&E': 20, 'A&M RECORDS': 21, 'A+E NETWORKS': 22, 'A+R': 23, 'A-FILM': 24, 'AAA': 25, 'AB VIDÉO': 26, 'AB VIDEO': 26, 'ABC - (AUSTRALIAN BROADCASTING CORPORATION)': 27, 'ABC': 27, 'ABKCO': 28, 'ABSOLUT MEDIEN': 29, 'ABSOLUTE': 30, 'ACCENT FILM ENTERTAINMENT': 31, 'ACCENTUS': 32, 'ACORN MEDIA': 33, 'AD VITAM': 34, 'ADA': 35, 'ADITYA VIDEOS': 36, 'ADSO FILMS': 37, 'AFM RECORDS': 38, 'AGFA': 39, 'AIX RECORDS': 40, 'ALAMODE FILM': 41, 'ALBA RECORDS': 42, 'ALBANY RECORDS': 43, 'ALBATROS': 44, 'ALCHEMY': 45, 'ALIVE': 46, 'ALL ANIME': 47, 'ALL INTERACTIVE ENTERTAINMENT': 48, 'ALLEGRO': 49, 'ALLIANCE': 50, 'ALPHA MUSIC': 51, 'ALTERDYSTRYBUCJA': 52, 'ALTERED INNOCENCE': 53, 'ALTITUDE FILM DISTRIBUTION': 54, 'ALUCARD RECORDS': 55, 'AMAZING D.C.': 56, 'AMAZING DC': 56, 'AMMO CONTENT': 57, 'AMUSE SOFT ENTERTAINMENT': 58, 'ANCONNECT': 59, 'ANEC': 60, 'ANIMATSU': 61, 'ANIME HOUSE': 62, 'ANIME LTD': 63, 'ANIME WORKS': 64, 'ANIMEIGO': 65, 'ANIPLEX': 66, 'ANOLIS ENTERTAINMENT': 67, 'ANOTHER WORLD ENTERTAINMENT': 68, 'AP INTERNATIONAL': 69, 'APPLE': 70, 'ARA MEDIA': 71, 'ARBELOS': 72, 'ARC ENTERTAINMENT': 73, 'ARP SÉLECTION': 74, 'ARP SELECTION': 74, 'ARROW': 75, 'ART SERVICE': 76, 'ART VISION': 77, 'ARTE ÉDITIONS': 78, 'ARTE EDITIONS': 78, 'ARTE VIDÉO': 79, 'ARTE VIDEO': 79, 'ARTHAUS MUSIK': 80, 'ARTIFICIAL EYE': 81, 'ARTSPLOITATION FILMS': 82, 'ARTUS FILMS': 83, 'ASCOT ELITE HOME ENTERTAINMENT': 84, 'ASIA VIDEO': 85, 'ASMIK ACE': 86, 'ASTRO RECORDS & FILMWORKS': 87, 'ASYLUM': 88, 'ATLANTIC FILM': 89, 'ATLANTIC RECORDS': 90, 'ATLAS FILM': 91, 'AUDIO VISUAL ENTERTAINMENT': 92, 'AURO-3D CREATIVE LABEL': 93, 'AURUM': 94, 'AV VISIONEN': 95, 'AV-JET': 96, 'AVALON': 97, 'AVENTI': 98, 'AVEX TRAX': 99, 'AXIOM': 100, 'AXIS RECORDS': 101, 'AYNGARAN': 102, 'BAC FILMS': 103, 'BACH FILMS': 104, 'BANDAI VISUAL': 105, 'BARCLAY': 106, 'BBC': 107, 'BRITISH BROADCASTING CORPORATION': 107, 'BBI FILMS': 108, 'BBI': 108, 'BCI HOME ENTERTAINMENT': 109, 'BEGGARS BANQUET': 110, 'BEL AIR CLASSIQUES': 111, 'BELGA FILMS': 112, 'BELVEDERE': 113, 'BENELUX FILM DISTRIBUTORS': 114, 'BENNETT-WATT MEDIA': 115, 'BERLIN CLASSICS': 116, 'BERLINER PHILHARMONIKER RECORDINGS': 117, 'BEST ENTERTAINMENT': 118, 'BEYOND HOME ENTERTAINMENT': 119, 'BFI VIDEO': 120, 'BFI': 120, 'BRITISH FILM INSTITUTE': 120, 'BFS ENTERTAINMENT': 121, 'BFS': 121, 'BHAVANI': 122, 'BIBER RECORDS': 123, 'BIG HOME VIDEO': 124, 'BILDSTÖRUNG': 125, 'BILDSTORUNG': 125, 'BILL ZEBUB': 126, 'BIRNENBLATT': 127, 'BIT WEL': 128, 'BLACK BOX': 129, 'BLACK HILL PICTURES': 130, 'BLACK HILL': 130, 'BLACK HOLE RECORDINGS': 131, 'BLACK HOLE': 131, 'BLAQOUT': 132, 'BLAUFIELD MUSIC': 133, 'BLAUFIELD': 133, 'BLOCKBUSTER ENTERTAINMENT': 134, 'BLOCKBUSTER': 134, 'BLU PHASE MEDIA': 135, 'BLU-RAY ONLY': 136, 'BLU-RAY': 136, 'BLURAY ONLY': 136, 'BLURAY': 136, 'BLUE GENTIAN RECORDS': 137, 'BLUE KINO': 138, 'BLUE UNDERGROUND': 139, 'BMG/ARISTA': 140, 'BMG': 140, 'BMGARISTA': 140, 'BMG ARISTA': 140, 'ARISTA': - 140, 'ARISTA/BMG': 140, 'ARISTABMG': 140, 'ARISTA BMG': 140, 'BONTON FILM': 141, 'BONTON': 141, 'BOOMERANG PICTURES': 142, 'BOOMERANG': 142, 'BQHL ÉDITIONS': 143, 'BQHL EDITIONS': 143, 'BQHL': 143, 'BREAKING GLASS': 144, 'BRIDGESTONE': 145, 'BRINK': 146, 'BROAD GREEN PICTURES': 147, 'BROAD GREEN': 147, 'BUSCH MEDIA GROUP': 148, 'BUSCH': 148, 'C MAJOR': 149, 'C.B.S.': 150, 'CAICHANG': 151, 'CALIFÓRNIA FILMES': 152, 'CALIFORNIA FILMES': 152, 'CALIFORNIA': 152, 'CAMEO': 153, 'CAMERA OBSCURA': 154, 'CAMERATA': 155, 'CAMP MOTION PICTURES': 156, 'CAMP MOTION': 156, 'CAPELIGHT PICTURES': 157, 'CAPELIGHT': 157, 'CAPITOL': 159, 'CAPITOL RECORDS': 159, 'CAPRICCI': 160, 'CARGO RECORDS': 161, 'CARLOTTA FILMS': 162, 'CARLOTTA': 162, 'CARLOTA': 162, 'CARMEN FILM': 163, 'CASCADE': 164, 'CATCHPLAY': 165, 'CAULDRON FILMS': 166, 'CAULDRON': 166, 'CBS TELEVISION STUDIOS': 167, 'CBS': 167, 'CCTV': 168, 'CCV ENTERTAINMENT': 169, 'CCV': 169, 'CD BABY': 170, 'CD LAND': 171, 'CECCHI GORI': 172, 'CENTURY MEDIA': 173, 'CHUAN XUN SHI DAI MULTIMEDIA': 174, 'CINE-ASIA': 175, 'CINÉART': 176, 'CINEART': 176, 'CINEDIGM': 177, 'CINEFIL IMAGICA': 178, 'CINEMA EPOCH': 179, 'CINEMA GUILD': 180, 'CINEMA LIBRE STUDIOS': 181, 'CINEMA MONDO': 182, 'CINEMATIC VISION': 183, 'CINEPLOIT RECORDS': 184, 'CINESTRANGE EXTREME': 185, 'CITEL VIDEO': 186, 'CITEL': 186, 'CJ ENTERTAINMENT': 187, 'CJ': 187, 'CLASSIC MEDIA': 188, 'CLASSICFLIX': 189, 'CLASSICLINE': 190, 'CLAUDIO RECORDS': 191, 'CLEAR VISION': 192, 'CLEOPATRA': 193, 'CLOSE UP': 194, 'CMS MEDIA LIMITED': 195, 'CMV LASERVISION': 196, 'CN ENTERTAINMENT': 197, 'CODE RED': 198, 'COHEN MEDIA GROUP': 199, 'COHEN': 199, 'COIN DE MIRE CINÉMA': 200, 'COIN DE MIRE CINEMA': 200, 'COLOSSEO FILM': 201, 'COLUMBIA': 203, 'COLUMBIA PICTURES': 203, 'COLUMBIA/TRI-STAR': 204, 'TRI-STAR': 204, 'COMMERCIAL MARKETING': 205, 'CONCORD MUSIC GROUP': 206, 'CONCORDE VIDEO': 207, 'CONDOR': 208, 'CONSTANTIN FILM': 209, 'CONSTANTIN': 209, 'CONSTANTINO FILMES': 210, 'CONSTANTINO': 210, 'CONSTRUCTIVE MEDIA SERVICE': 211, 'CONSTRUCTIVE': 211, 'CONTENT ZONE': 212, 'CONTENTS GATE': 213, 'COQUEIRO VERDE': 214, 'CORNERSTONE MEDIA': 215, 'CORNERSTONE': 215, 'CP DIGITAL': 216, 'CREST MOVIES': 217, 'CRITERION': 218, 'CRITERION COLLECTION': - 218, 'CC': 218, 'CRYSTAL CLASSICS': 219, 'CULT EPICS': 220, 'CULT FILMS': 221, 'CULT VIDEO': 222, 'CURZON FILM WORLD': 223, 'D FILMS': 224, "D'AILLY COMPANY": 225, 'DAILLY COMPANY': 225, 'D AILLY COMPANY': 225, "D'AILLY": 225, 'DAILLY': 225, 'D AILLY': 225, 'DA CAPO': 226, 'DA MUSIC': 227, "DALL'ANGELO PICTURES": 228, 'DALLANGELO PICTURES': 228, "DALL'ANGELO": 228, 'DALL ANGELO PICTURES': 228, 'DALL ANGELO': 228, 'DAREDO': 229, 'DARK FORCE ENTERTAINMENT': 230, 'DARK FORCE': 230, 'DARK SIDE RELEASING': 231, 'DARK SIDE': 231, 'DAZZLER MEDIA': 232, 'DAZZLER': 232, 'DCM PICTURES': 233, 'DCM': 233, 'DEAPLANETA': 234, 'DECCA': 235, 'DEEPJOY': 236, 'DEFIANT SCREEN ENTERTAINMENT': 237, 'DEFIANT SCREEN': 237, 'DEFIANT': 237, 'DELOS': 238, 'DELPHIAN RECORDS': 239, 'DELPHIAN': 239, 'DELTA MUSIC & ENTERTAINMENT': 240, 'DELTA MUSIC AND ENTERTAINMENT': 240, 'DELTA MUSIC ENTERTAINMENT': 240, 'DELTA MUSIC': 240, 'DELTAMAC CO. LTD.': 241, 'DELTAMAC CO LTD': 241, 'DELTAMAC CO': 241, 'DELTAMAC': 241, 'DEMAND MEDIA': 242, 'DEMAND': 242, 'DEP': 243, 'DEUTSCHE GRAMMOPHON': 244, 'DFW': 245, 'DGM': 246, 'DIAPHANA': 247, 'DIGIDREAMS STUDIOS': 248, 'DIGIDREAMS': 248, 'DIGITAL ENVIRONMENTS': 249, 'DIGITAL': 249, 'DISCOTEK MEDIA': 250, 'DISCOVERY CHANNEL': 251, 'DISCOVERY': 251, 'DISK KINO': 252, 'DISNEY / BUENA VISTA': 253, 'DISNEY': 253, 'BUENA VISTA': 253, 'DISNEY BUENA VISTA': 253, 'DISTRIBUTION SELECT': 254, 'DIVISA': 255, 'DNC ENTERTAINMENT': 256, 'DNC': 256, 'DOGWOOF': 257, 'DOLMEN HOME VIDEO': 258, 'DOLMEN': 258, 'DONAU FILM': 259, 'DONAU': 259, 'DORADO FILMS': 260, 'DORADO': 260, 'DRAFTHOUSE FILMS': 261, 'DRAFTHOUSE': 261, 'DRAGON FILM ENTERTAINMENT': 262, 'DRAGON ENTERTAINMENT': 262, 'DRAGON FILM': 262, 'DRAGON': 262, 'DREAMWORKS': 263, 'DRIVE ON RECORDS': 264, 'DRIVE ON': 264, 'DRIVE-ON': 264, 'DRIVEON': 264, 'DS MEDIA': 265, 'DTP ENTERTAINMENT AG': 266, 'DTP ENTERTAINMENT': 266, 'DTP AG': 266, 'DTP': 266, 'DTS ENTERTAINMENT': 267, 'DTS': 267, 'DUKE MARKETING': 268, 'DUKE VIDEO DISTRIBUTION': 269, 'DUKE': 269, 'DUTCH FILMWORKS': 270, 'DUTCH': 270, 'DVD INTERNATIONAL': 271, 'DVD': 271, 'DYBEX': 272, 'DYNAMIC': 273, 'DYNIT': 274, 'E1 ENTERTAINMENT': 275, 'E1': 275, 'EAGLE ENTERTAINMENT': 276, 'EAGLE HOME ENTERTAINMENT PVT.LTD.': - 277, 'EAGLE HOME ENTERTAINMENT PVTLTD': 277, 'EAGLE HOME ENTERTAINMENT PVT LTD': 277, 'EAGLE HOME ENTERTAINMENT': 277, 'EAGLE PICTURES': 278, 'EAGLE ROCK ENTERTAINMENT': 279, 'EAGLE ROCK': 279, 'EAGLE VISION MEDIA': 280, 'EAGLE VISION': 280, 'EARMUSIC': 281, 'EARTH ENTERTAINMENT': 282, 'EARTH': 282, 'ECHO BRIDGE ENTERTAINMENT': 283, 'ECHO BRIDGE': 283, 'EDEL GERMANY GMBH': 284, 'EDEL GERMANY': 284, 'EDEL RECORDS': 285, 'EDITION TONFILM': 286, 'EDITIONS MONTPARNASSE': 287, 'EDKO FILMS LTD.': 288, 'EDKO FILMS LTD': 288, 'EDKO FILMS': 288, 'EDKO': 288, "EIN'S M&M CO": 289, 'EINS M&M CO': 289, "EIN'S M&M": 289, 'EINS M&M': 289, 'ELEA-MEDIA': 290, 'ELEA MEDIA': 290, 'ELEA': 290, 'ELECTRIC PICTURE': 291, 'ELECTRIC': 291, 'ELEPHANT FILMS': 292, 'ELEPHANT': 292, 'ELEVATION': 293, 'EMI': 294, 'EMON': 295, 'EMS': 296, 'EMYLIA': 297, 'ENE MEDIA': 298, 'ENE': 298, 'ENTERTAINMENT IN VIDEO': 299, 'ENTERTAINMENT IN': 299, 'ENTERTAINMENT ONE': 300, 'ENTERTAINMENT ONE FILMS CANADA INC.': 301, 'ENTERTAINMENT ONE FILMS CANADA INC': 301, 'ENTERTAINMENT ONE FILMS CANADA': 301, 'ENTERTAINMENT ONE CANADA INC': 301, - 'ENTERTAINMENT ONE CANADA': 301, 'ENTERTAINMENTONE': 302, 'EONE': 303, 'EOS': 304, 'EPIC PICTURES': 305, 'EPIC': 305, 'EPIC RECORDS': 306, 'ERATO': 307, 'EROS': 308, 'ESC EDITIONS': 309, 'ESCAPI MEDIA BV': 310, 'ESOTERIC RECORDINGS': 311, 'ESPN FILMS': 312, 'EUREKA ENTERTAINMENT': 313, 'EUREKA': 313, 'EURO PICTURES': 314, 'EURO VIDEO': 315, 'EUROARTS': 316, 'EUROPA FILMES': 317, 'EUROPA': 317, 'EUROPACORP': 318, 'EUROZOOM': 319, 'EXCEL': 320, 'EXPLOSIVE MEDIA': 321, 'EXPLOSIVE': 321, 'EXTRALUCID FILMS': 322, 'EXTRALUCID': 322, 'EYE SEE MOVIES': 323, 'EYE SEE': 323, 'EYK MEDIA': 324, 'EYK': 324, 'FABULOUS FILMS': 325, 'FABULOUS': 325, 'FACTORIS FILMS': 326, 'FACTORIS': 326, 'FARAO RECORDS': 327, 'FARBFILM HOME ENTERTAINMENT': 328, 'FARBFILM ENTERTAINMENT': 328, 'FARBFILM HOME': 328, 'FARBFILM': 328, 'FEELGOOD ENTERTAINMENT': 329, 'FEELGOOD': 329, 'FERNSEHJUWELEN': 330, 'FILM CHEST': 331, 'FILM MEDIA': 332, 'FILM MOVEMENT': 333, 'FILM4': 334, 'FILMART': 335, 'FILMAURO': 336, 'FILMAX': 337, 'FILMCONFECT HOME ENTERTAINMENT': 338, 'FILMCONFECT ENTERTAINMENT': 338, 'FILMCONFECT HOME': 338, 'FILMCONFECT': 338, 'FILMEDIA': 339, 'FILMJUWELEN': 340, 'FILMOTEKA NARODAWA': 341, 'FILMRISE': 342, 'FINAL CUT ENTERTAINMENT': 343, 'FINAL CUT': 343, 'FIREHOUSE 12 RECORDS': 344, 'FIREHOUSE 12': 344, 'FIRST INTERNATIONAL PRODUCTION': 345, 'FIRST INTERNATIONAL': 345, 'FIRST LOOK STUDIOS': 346, 'FIRST LOOK': 346, 'FLAGMAN TRADE': 347, 'FLASHSTAR FILMES': 348, 'FLASHSTAR': 348, 'FLICKER ALLEY': 349, 'FNC ADD CULTURE': 350, 'FOCUS FILMES': 351, 'FOCUS': 351, 'FOKUS MEDIA': 352, 'FOKUSA': 352, 'FOX PATHE EUROPA': 353, 'FOX PATHE': 353, 'FOX EUROPA': 353, 'FOX/MGM': 354, 'FOX MGM': 354, 'MGM': 354, 'MGM/FOX': 354, 'FOX': 354, 'FPE': 355, 'FRANCE TÉLÉVISIONS DISTRIBUTION': 356, 'FRANCE TELEVISIONS DISTRIBUTION': 356, 'FRANCE TELEVISIONS': 356, 'FRANCE': 356, 'FREE DOLPHIN ENTERTAINMENT': 357, 'FREE DOLPHIN': 357, 'FREESTYLE DIGITAL MEDIA': 358, 'FREESTYLE DIGITAL': 358, 'FREESTYLE': 358, 'FREMANTLE HOME ENTERTAINMENT': 359, 'FREMANTLE ENTERTAINMENT': 359, 'FREMANTLE HOME': 359, 'FREMANTL': 359, 'FRENETIC FILMS': 360, 'FRENETIC': 360, 'FRONTIER WORKS': 361, 'FRONTIER': 361, 'FRONTIERS MUSIC': 362, 'FRONTIERS RECORDS': 363, 'FS FILM OY': 364, 'FS FILM': - 364, 'FULL MOON FEATURES': 365, 'FULL MOON': 365, 'FUN CITY EDITIONS': 366, 'FUN CITY': 366, 'FUNIMATION ENTERTAINMENT': 367, 'FUNIMATION': 367, 'FUSION': 368, 'FUTUREFILM': 369, 'G2 PICTURES': 370, 'G2': 370, 'GAGA COMMUNICATIONS': 371, 'GAGA': 371, 'GAIAM': 372, 'GALAPAGOS': 373, 'GAMMA HOME ENTERTAINMENT': 374, 'GAMMA ENTERTAINMENT': 374, 'GAMMA HOME': 374, 'GAMMA': 374, 'GARAGEHOUSE PICTURES': 375, 'GARAGEHOUSE': 375, 'GARAGEPLAY (車庫娛樂)': 376, '車庫娛樂': 376, 'GARAGEPLAY (Che Ku Yu Le )': 376, 'GARAGEPLAY': 376, 'Che Ku Yu Le': 376, 'GAUMONT': 377, 'GEFFEN': 378, 'GENEON ENTERTAINMENT': 379, 'GENEON': 379, 'GENEON UNIVERSAL ENTERTAINMENT': 380, 'GENERAL VIDEO RECORDING': 381, 'GLASS DOLL FILMS': 382, 'GLASS DOLL': 382, 'GLOBE MUSIC MEDIA': 383, 'GLOBE MUSIC': 383, 'GLOBE MEDIA': 383, 'GLOBE': 383, 'GO ENTERTAIN': 384, 'GO': 384, 'GOLDEN HARVEST': 385, 'GOOD!MOVIES': 386, - 'GOOD! MOVIES': 386, 'GOOD MOVIES': 386, 'GRAPEVINE VIDEO': 387, 'GRAPEVINE': 387, 'GRASSHOPPER FILM': 388, 'GRASSHOPPER FILMS': 388, 'GRASSHOPPER': 388, 'GRAVITAS VENTURES': 389, 'GRAVITAS': 389, 'GREAT MOVIES': 390, 'GREAT': 390, - 'GREEN APPLE ENTERTAINMENT': 391, 'GREEN ENTERTAINMENT': 391, 'GREEN APPLE': 391, 'GREEN': 391, 'GREENNARAE MEDIA': 392, 'GREENNARAE': 392, 'GRINDHOUSE RELEASING': 393, 'GRINDHOUSE': 393, 'GRIND HOUSE': 393, 'GRYPHON ENTERTAINMENT': 394, 'GRYPHON': 394, 'GUNPOWDER & SKY': 395, 'GUNPOWDER AND SKY': 395, 'GUNPOWDER SKY': 395, 'GUNPOWDER + SKY': 395, 'GUNPOWDER': 395, 'HANABEE ENTERTAINMENT': 396, 'HANABEE': 396, 'HANNOVER HOUSE': 397, 'HANNOVER': 397, 'HANSESOUND': 398, 'HANSE SOUND': 398, 'HANSE': 398, 'HAPPINET': 399, 'HARMONIA MUNDI': 400, 'HARMONIA': 400, 'HBO': 401, 'HDC': 402, 'HEC': 403, 'HELL & BACK RECORDINGS': 404, 'HELL AND BACK RECORDINGS': 404, 'HELL & BACK': 404, 'HELL AND BACK': 404, "HEN'S TOOTH VIDEO": 405, 'HENS TOOTH VIDEO': 405, "HEN'S TOOTH": 405, 'HENS TOOTH': 405, 'HIGH FLIERS': 406, 'HIGHLIGHT': 407, 'HILLSONG': 408, 'HISTORY CHANNEL': 409, 'HISTORY': 409, 'HK VIDÉO': 410, 'HK VIDEO': 410, 'HK': 410, 'HMH HAMBURGER MEDIEN HAUS': 411, 'HAMBURGER MEDIEN HAUS': 411, 'HMH HAMBURGER MEDIEN': 411, 'HMH HAMBURGER': 411, 'HMH': 411, 'HOLLYWOOD CLASSIC ENTERTAINMENT': 412, 'HOLLYWOOD CLASSIC': 412, 'HOLLYWOOD PICTURES': 413, 'HOLLYWOOD': 413, 'HOPSCOTCH ENTERTAINMENT': 414, 'HOPSCOTCH': 414, 'HPM': 415, 'HÄNNSLER CLASSIC': 416, 'HANNSLER CLASSIC': 416, 'HANNSLER': 416, 'I-CATCHER': 417, 'I CATCHER': 417, 'ICATCHER': 417, 'I-ON NEW MEDIA': 418, 'I ON NEW MEDIA': 418, 'ION NEW MEDIA': 418, 'ION MEDIA': 418, 'I-ON': 418, 'ION': 418, 'IAN PRODUCTIONS': 419, 'IAN': 419, 'ICESTORM': 420, 'ICON FILM DISTRIBUTION': 421, 'ICON DISTRIBUTION': 421, 'ICON FILM': 421, 'ICON': 421, 'IDEALE AUDIENCE': 422, 'IDEALE': 422, 'IFC FILMS': 423, 'IFC': 423, 'IFILM': 424, 'ILLUSIONS UNLTD.': 425, 'ILLUSIONS UNLTD': 425, 'ILLUSIONS': 425, 'IMAGE ENTERTAINMENT': 426, 'IMAGE': 426, - 'IMAGEM FILMES': 427, 'IMAGEM': 427, 'IMOVISION': 428, 'IMPERIAL CINEPIX': 429, 'IMPRINT': 430, 'IMPULS HOME ENTERTAINMENT': 431, 'IMPULS ENTERTAINMENT': 431, 'IMPULS HOME': 431, 'IMPULS': 431, 'IN-AKUSTIK': 432, 'IN AKUSTIK': 432, 'INAKUSTIK': 432, 'INCEPTION MEDIA GROUP': 433, 'INCEPTION MEDIA': 433, 'INCEPTION GROUP': 433, 'INCEPTION': 433, 'INDEPENDENT': 434, 'INDICAN': 435, 'INDIE RIGHTS': 436, 'INDIE': 436, 'INDIGO': 437, 'INFO': 438, 'INJOINGAN': 439, 'INKED PICTURES': 440, 'INKED': 440, 'INSIDE OUT MUSIC': 441, 'INSIDE MUSIC': 441, 'INSIDE OUT': 441, 'INSIDE': 441, 'INTERCOM': 442, 'INTERCONTINENTAL VIDEO': 443, 'INTERCONTINENTAL': 443, 'INTERGROOVE': 444, - 'INTERSCOPE': 445, 'INVINCIBLE PICTURES': 446, 'INVINCIBLE': 446, 'ISLAND/MERCURY': 447, 'ISLAND MERCURY': 447, 'ISLANDMERCURY': 447, 'ISLAND & MERCURY': 447, 'ISLAND AND MERCURY': 447, 'ISLAND': 447, 'ITN': 448, 'ITV DVD': 449, 'ITV': 449, 'IVC': 450, 'IVE ENTERTAINMENT': 451, 'IVE': 451, 'J&R ADVENTURES': 452, 'J&R': 452, 'JR': 452, 'JAKOB': 453, 'JONU MEDIA': 454, 'JONU': 454, 'JRB PRODUCTIONS': 455, 'JRB': 455, 'JUST BRIDGE ENTERTAINMENT': 456, 'JUST BRIDGE': 456, 'JUST ENTERTAINMENT': 456, 'JUST': 456, 'KABOOM ENTERTAINMENT': 457, 'KABOOM': 457, 'KADOKAWA ENTERTAINMENT': 458, 'KADOKAWA': 458, 'KAIROS': 459, 'KALEIDOSCOPE ENTERTAINMENT': 460, 'KALEIDOSCOPE': 460, 'KAM & RONSON ENTERPRISES': 461, 'KAM & RONSON': 461, 'KAM&RONSON ENTERPRISES': 461, 'KAM&RONSON': 461, 'KAM AND RONSON ENTERPRISES': 461, 'KAM AND RONSON': 461, 'KANA HOME VIDEO': 462, 'KARMA FILMS': 463, 'KARMA': 463, 'KATZENBERGER': 464, 'KAZE': 465, 'KBS MEDIA': 466, 'KBS': 466, 'KD MEDIA': 467, 'KD': 467, 'KING MEDIA': 468, 'KING': 468, 'KING RECORDS': 469, 'KINO LORBER': 470, 'KINO': 470, 'KINO SWIAT': 471, 'KINOKUNIYA': 472, 'KINOWELT HOME ENTERTAINMENT/DVD': 473, 'KINOWELT HOME ENTERTAINMENT': 473, 'KINOWELT ENTERTAINMENT': 473, 'KINOWELT HOME DVD': 473, 'KINOWELT ENTERTAINMENT/DVD': 473, 'KINOWELT DVD': 473, 'KINOWELT': 473, 'KIT PARKER FILMS': 474, 'KIT PARKER': 474, 'KITTY MEDIA': 475, 'KNM HOME ENTERTAINMENT': 476, 'KNM ENTERTAINMENT': 476, 'KNM HOME': 476, 'KNM': 476, 'KOBA FILMS': 477, 'KOBA': 477, 'KOCH ENTERTAINMENT': 478, 'KOCH MEDIA': 479, 'KOCH': 479, 'KRAKEN RELEASING': 480, 'KRAKEN': 480, 'KSCOPE': 481, 'KSM': 482, 'KULTUR': 483, "L'ATELIER D'IMAGES": 484, "LATELIER D'IMAGES": 484, "L'ATELIER DIMAGES": 484, 'LATELIER DIMAGES': 484, "L ATELIER D'IMAGES": 484, "L'ATELIER D IMAGES": 484, - 'L ATELIER D IMAGES': 484, "L'ATELIER": 484, 'L ATELIER': 484, 'LATELIER': 484, 'LA AVENTURA AUDIOVISUAL': 485, 'LA AVENTURA': 485, 'LACE GROUP': 486, 'LACE': 486, 'LASER PARADISE': 487, 'LAYONS': 488, 'LCJ EDITIONS': 489, 'LCJ': 489, 'LE CHAT QUI FUME': 490, 'LE PACTE': 491, 'LEDICK FILMHANDEL': 492, 'LEGEND': 493, 'LEOMARK STUDIOS': 494, 'LEOMARK': 494, 'LEONINE FILMS': 495, 'LEONINE': 495, 'LICHTUNG MEDIA LTD': 496, 'LICHTUNG LTD': 496, 'LICHTUNG MEDIA LTD.': 496, 'LICHTUNG LTD.': 496, 'LICHTUNG MEDIA': 496, 'LICHTUNG': 496, 'LIGHTHOUSE HOME ENTERTAINMENT': 497, 'LIGHTHOUSE ENTERTAINMENT': 497, 'LIGHTHOUSE HOME': 497, 'LIGHTHOUSE': 497, 'LIGHTYEAR': 498, 'LIONSGATE FILMS': 499, 'LIONSGATE': 499, 'LIZARD CINEMA TRADE': 500, 'LLAMENTOL': 501, 'LOBSTER FILMS': 502, 'LOBSTER': 502, 'LOGON': 503, 'LORBER FILMS': 504, 'LORBER': 504, 'LOS BANDITOS FILMS': 505, 'LOS BANDITOS': 505, 'LOUD & PROUD RECORDS': 506, 'LOUD AND PROUD RECORDS': 506, 'LOUD & PROUD': 506, 'LOUD AND PROUD': 506, 'LSO LIVE': 507, 'LUCASFILM': 508, 'LUCKY RED': 509, 'LUMIÈRE HOME ENTERTAINMENT': 510, 'LUMIERE HOME ENTERTAINMENT': 510, 'LUMIERE ENTERTAINMENT': 510, 'LUMIERE HOME': 510, 'LUMIERE': 510, 'M6 VIDEO': 511, 'M6': 511, 'MAD DIMENSION': 512, 'MADMAN ENTERTAINMENT': 513, 'MADMAN': 513, 'MAGIC BOX': 514, 'MAGIC PLAY': 515, 'MAGNA HOME ENTERTAINMENT': 516, 'MAGNA ENTERTAINMENT': 516, 'MAGNA HOME': 516, 'MAGNA': 516, 'MAGNOLIA PICTURES': 517, 'MAGNOLIA': 517, 'MAIDEN JAPAN': 518, 'MAIDEN': 518, 'MAJENG MEDIA': 519, 'MAJENG': 519, 'MAJESTIC HOME ENTERTAINMENT': 520, 'MAJESTIC ENTERTAINMENT': 520, 'MAJESTIC HOME': 520, 'MAJESTIC': 520, 'MANGA HOME ENTERTAINMENT': 521, 'MANGA ENTERTAINMENT': 521, 'MANGA HOME': 521, 'MANGA': 521, 'MANTA LAB': 522, 'MAPLE STUDIOS': 523, 'MAPLE': 523, 'MARCO POLO PRODUCTION': - 524, 'MARCO POLO': 524, 'MARIINSKY': 525, 'MARVEL STUDIOS': 526, 'MARVEL': 526, 'MASCOT RECORDS': 527, 'MASCOT': 527, 'MASSACRE VIDEO': 528, 'MASSACRE': 528, 'MATCHBOX': 529, 'MATRIX D': 530, 'MAXAM': 531, 'MAYA HOME ENTERTAINMENT': 532, 'MAYA ENTERTAINMENT': 532, 'MAYA HOME': 532, 'MAYAT': 532, 'MDG': 533, 'MEDIA BLASTERS': 534, 'MEDIA FACTORY': 535, 'MEDIA TARGET DISTRIBUTION': 536, 'MEDIA TARGET': 536, 'MEDIAINVISION': 537, 'MEDIATOON': 538, 'MEDIATRES ESTUDIO': 539, 'MEDIATRES STUDIO': 539, 'MEDIATRES': 539, 'MEDICI ARTS': 540, 'MEDICI CLASSICS': 541, 'MEDIUMRARE ENTERTAINMENT': 542, 'MEDIUMRARE': 542, 'MEDUSA': 543, 'MEGASTAR': 544, 'MEI AH': 545, 'MELI MÉDIAS': 546, 'MELI MEDIAS': 546, 'MEMENTO FILMS': 547, 'MEMENTO': 547, 'MENEMSHA FILMS': 548, 'MENEMSHA': 548, 'MERCURY': 549, 'MERCURY STUDIOS': 550, 'MERGE SOFT PRODUCTIONS': 551, 'MERGE PRODUCTIONS': 551, 'MERGE SOFT': 551, 'MERGE': 551, 'METAL BLADE RECORDS': 552, 'METAL BLADE': 552, 'METEOR': 553, 'METRO-GOLDWYN-MAYER': 554, 'METRO GOLDWYN MAYER': 554, 'METROGOLDWYNMAYER': 554, 'METRODOME VIDEO': 555, 'METRODOME': 555, 'METROPOLITAN': 556, 'MFA+': - 557, 'MFA': 557, 'MIG FILMGROUP': 558, 'MIG': 558, 'MILESTONE': 559, 'MILL CREEK ENTERTAINMENT': 560, 'MILL CREEK': 560, 'MILLENNIUM MEDIA': 561, 'MILLENNIUM': 561, 'MIRAGE ENTERTAINMENT': 562, 'MIRAGE': 562, 'MIRAMAX': 563, - 'MISTERIYA ZVUKA': 564, 'MK2': 565, 'MODE RECORDS': 566, 'MODE': 566, 'MOMENTUM PICTURES': 567, 'MONDO HOME ENTERTAINMENT': 568, 'MONDO ENTERTAINMENT': 568, 'MONDO HOME': 568, 'MONDO MACABRO': 569, 'MONGREL MEDIA': 570, 'MONOLIT': 571, 'MONOLITH VIDEO': 572, 'MONOLITH': 572, 'MONSTER PICTURES': 573, 'MONSTER': 573, 'MONTEREY VIDEO': 574, 'MONTEREY': 574, 'MONUMENT RELEASING': 575, 'MONUMENT': 575, 'MORNINGSTAR': 576, 'MORNING STAR': 576, 'MOSERBAER': 577, 'MOVIEMAX': 578, 'MOVINSIDE': 579, 'MPI MEDIA GROUP': 580, 'MPI MEDIA': 580, 'MPI': 580, 'MR. BONGO FILMS': 581, 'MR BONGO FILMS': 581, 'MR BONGO': 581, 'MRG (MERIDIAN)': 582, 'MRG MERIDIAN': 582, 'MRG': 582, 'MERIDIAN': 582, 'MUBI': 583, 'MUG SHOT PRODUCTIONS': 584, 'MUG SHOT': 584, 'MULTIMUSIC': 585, 'MULTI-MUSIC': 585, 'MULTI MUSIC': 585, 'MUSE': 586, 'MUSIC BOX FILMS': 587, 'MUSIC BOX': 587, 'MUSICBOX': 587, 'MUSIC BROKERS': 588, 'MUSIC THEORIES': 589, 'MUSIC VIDEO DISTRIBUTORS': 590, 'MUSIC VIDEO': 590, 'MUSTANG ENTERTAINMENT': 591, 'MUSTANG': 591, 'MVD VISUAL': 592, 'MVD': 592, 'MVD/VSC': 593, 'MVL': 594, 'MVM ENTERTAINMENT': 595, 'MVM': 595, 'MYNDFORM': 596, 'MYSTIC NIGHT PICTURES': 597, 'MYSTIC NIGHT': 597, 'NAMELESS MEDIA': 598, 'NAMELESS': 598, 'NAPALM RECORDS': 599, 'NAPALM': 599, 'NATIONAL ENTERTAINMENT MEDIA': 600, 'NATIONAL ENTERTAINMENT': 600, 'NATIONAL MEDIA': 600, 'NATIONAL FILM ARCHIVE': 601, 'NATIONAL ARCHIVE': 601, 'NATIONAL FILM': 601, 'NATIONAL GEOGRAPHIC': 602, 'NAT GEO TV': 602, 'NAT GEO': 602, 'NGO': 602, 'NAXOS': 603, 'NBCUNIVERSAL ENTERTAINMENT JAPAN': 604, 'NBC UNIVERSAL ENTERTAINMENT JAPAN': 604, 'NBCUNIVERSAL JAPAN': 604, 'NBC UNIVERSAL JAPAN': 604, 'NBC JAPAN': 604, 'NBO ENTERTAINMENT': 605, 'NBO': 605, 'NEOS': 606, 'NETFLIX': 607, 'NETWORK': 608, 'NEW BLOOD': 609, 'NEW DISC': 610, 'NEW KSM': 611, 'NEW LINE CINEMA': 612, 'NEW LINE': 612, 'NEW MOVIE TRADING CO. LTD': 613, 'NEW MOVIE TRADING CO LTD': 613, 'NEW MOVIE TRADING CO': 613, 'NEW MOVIE TRADING': 613, 'NEW WAVE FILMS': 614, 'NEW WAVE': 614, 'NFI': 615, - 'NHK': 616, 'NIPPONART': 617, 'NIS AMERICA': 618, 'NJUTAFILMS': 619, 'NOBLE ENTERTAINMENT': 620, 'NOBLE': 620, 'NORDISK FILM': 621, 'NORDISK': 621, 'NORSK FILM': 622, 'NORSK': 622, 'NORTH AMERICAN MOTION PICTURES': 623, 'NOS AUDIOVISUAIS': 624, 'NOTORIOUS PICTURES': 625, 'NOTORIOUS': 625, 'NOVA MEDIA': 626, 'NOVA': 626, 'NOVA SALES AND DISTRIBUTION': 627, 'NOVA SALES & DISTRIBUTION': 627, 'NSM': 628, 'NSM RECORDS': 629, 'NUCLEAR BLAST': 630, 'NUCLEUS FILMS': 631, 'NUCLEUS': 631, 'OBERLIN MUSIC': 632, 'OBERLIN': 632, 'OBRAS-PRIMAS DO CINEMA': 633, 'OBRAS PRIMAS DO CINEMA': 633, 'OBRASPRIMAS DO CINEMA': 633, 'OBRAS-PRIMAS CINEMA': 633, 'OBRAS PRIMAS CINEMA': 633, 'OBRASPRIMAS CINEMA': 633, 'OBRAS-PRIMAS': 633, 'OBRAS PRIMAS': 633, 'OBRASPRIMAS': 633, 'ODEON': 634, 'OFDB FILMWORKS': 635, 'OFDB': 635, 'OLIVE FILMS': 636, 'OLIVE': 636, 'ONDINE': 637, 'ONSCREEN FILMS': 638, 'ONSCREEN': 638, 'OPENING DISTRIBUTION': 639, 'OPERA AUSTRALIA': 640, 'OPTIMUM HOME ENTERTAINMENT': 641, 'OPTIMUM ENTERTAINMENT': 641, 'OPTIMUM HOME': 641, 'OPTIMUM': 641, 'OPUS ARTE': 642, 'ORANGE STUDIO': 643, 'ORANGE': 643, 'ORLANDO EASTWOOD FILMS': 644, 'ORLANDO FILMS': 644, 'ORLANDO EASTWOOD': 644, 'ORLANDO': 644, 'ORUSTAK PICTURES': 645, 'ORUSTAK': 645, 'OSCILLOSCOPE PICTURES': 646, 'OSCILLOSCOPE': 646, 'OUTPLAY': 647, 'PALISADES TARTAN': 648, 'PAN VISION': 649, 'PANVISION': 649, 'PANAMINT CINEMA': 650, 'PANAMINT': 650, 'PANDASTORM ENTERTAINMENT': 651, 'PANDA STORM ENTERTAINMENT': 651, 'PANDASTORM': 651, 'PANDA STORM': 651, 'PANDORA FILM': 652, 'PANDORA': 652, 'PANEGYRIC': 653, 'PANORAMA': 654, 'PARADE DECK FILMS': 655, 'PARADE DECK': 655, 'PARADISE': 656, 'PARADISO FILMS': 657, 'PARADOX': 658, 'PARAMOUNT PICTURES': 659, 'PARAMOUNT': 659, 'PARIS FILMES': 660, 'PARIS FILMS': 660, 'PARIS': 660, 'PARK CIRCUS': 661, 'PARLOPHONE': 662, 'PASSION RIVER': 663, 'PATHE DISTRIBUTION': 664, 'PATHE': 664, 'PBS': 665, 'PEACE ARCH TRINITY': 666, 'PECCADILLO PICTURES': 667, 'PEPPERMINT': 668, 'PHASE 4 FILMS': 669, 'PHASE 4': 669, 'PHILHARMONIA BAROQUE': 670, 'PICTURE HOUSE ENTERTAINMENT': 671, 'PICTURE ENTERTAINMENT': 671, 'PICTURE HOUSE': 671, 'PICTURE': 671, 'PIDAX': 672, 'PINK FLOYD RECORDS': 673, 'PINK FLOYD': 673, 'PINNACLE FILMS': 674, 'PINNACLE': 674, 'PLAIN': 675, 'PLATFORM ENTERTAINMENT LIMITED': 676, 'PLATFORM ENTERTAINMENT LTD': 676, 'PLATFORM ENTERTAINMENT LTD.': 676, 'PLATFORM ENTERTAINMENT': 676, 'PLATFORM': 676, 'PLAYARTE': 677, 'PLG UK CLASSICS': 678, 'PLG UK': - 678, 'PLG': 678, 'POLYBAND & TOPPIC VIDEO/WVG': 679, 'POLYBAND AND TOPPIC VIDEO/WVG': 679, 'POLYBAND & TOPPIC VIDEO WVG': 679, 'POLYBAND & TOPPIC VIDEO AND WVG': 679, 'POLYBAND & TOPPIC VIDEO & WVG': 679, 'POLYBAND AND TOPPIC VIDEO WVG': 679, 'POLYBAND AND TOPPIC VIDEO AND WVG': 679, 'POLYBAND AND TOPPIC VIDEO & WVG': 679, 'POLYBAND & TOPPIC VIDEO': 679, 'POLYBAND AND TOPPIC VIDEO': 679, 'POLYBAND & TOPPIC': 679, 'POLYBAND AND TOPPIC': 679, 'POLYBAND': 679, 'WVG': 679, 'POLYDOR': 680, 'PONY': 681, 'PONY CANYON': 682, 'POTEMKINE': 683, 'POWERHOUSE FILMS': 684, 'POWERHOUSE': 684, 'POWERSTATIOM': 685, 'PRIDE & JOY': 686, 'PRIDE AND JOY': 686, 'PRINZ MEDIA': 687, 'PRINZ': 687, 'PRIS AUDIOVISUAIS': 688, 'PRO VIDEO': 689, 'PRO-VIDEO': 689, 'PRO-MOTION': 690, 'PRO MOTION': 690, 'PROD. JRB': 691, 'PROD JRB': 691, 'PRODISC': 692, 'PROKINO': 693, 'PROVOGUE RECORDS': 694, 'PROVOGUE': 694, 'PROWARE': 695, 'PULP VIDEO': 696, 'PULP': 696, 'PULSE VIDEO': 697, 'PULSE': 697, 'PURE AUDIO RECORDINGS': 698, 'PURE AUDIO': 698, 'PURE FLIX ENTERTAINMENT': 699, 'PURE FLIX': 699, 'PURE ENTERTAINMENT': 699, 'PYRAMIDE VIDEO': 700, 'PYRAMIDE': 700, 'QUALITY FILMS': 701, 'QUALITY': 701, 'QUARTO VALLEY RECORDS': 702, 'QUARTO VALLEY': 702, 'QUESTAR': 703, 'R SQUARED FILMS': 704, 'R SQUARED': 704, 'RAPID EYE MOVIES': 705, 'RAPID EYE': 705, 'RARO VIDEO': 706, 'RARO': 706, 'RAROVIDEO U.S.': 707, 'RAROVIDEO US': 707, 'RARO VIDEO US': 707, 'RARO VIDEO U.S.': 707, 'RARO U.S.': 707, 'RARO US': 707, 'RAVEN BANNER RELEASING': 708, 'RAVEN BANNER': 708, 'RAVEN': 708, 'RAZOR DIGITAL ENTERTAINMENT': 709, 'RAZOR DIGITAL': 709, 'RCA': 710, 'RCO LIVE': 711, 'RCO': 711, 'RCV': 712, 'REAL GONE MUSIC': 713, 'REAL GONE': 713, 'REANIMEDIA': 714, 'REANI MEDIA': 714, 'REDEMPTION': 715, 'REEL': 716, 'RELIANCE HOME VIDEO & GAMES': 717, 'RELIANCE HOME VIDEO AND GAMES': 717, 'RELIANCE HOME VIDEO': 717, 'RELIANCE VIDEO': 717, 'RELIANCE HOME': 717, 'RELIANCE': 717, 'REM CULTURE': 718, 'REMAIN IN LIGHT': 719, 'REPRISE': 720, 'RESEN': 721, 'RETROMEDIA': 722, 'REVELATION FILMS LTD.': 723, 'REVELATION FILMS LTD': 723, 'REVELATION FILMS': 723, 'REVELATION LTD.': 723, 'REVELATION LTD': 723, 'REVELATION': 723, 'REVOLVER ENTERTAINMENT': 724, 'REVOLVER': 724, 'RHINO MUSIC': 725, 'RHINO': 725, 'RHV': 726, 'RIGHT STUF': 727, 'RIMINI EDITIONS': 728, 'RISING SUN MEDIA': 729, 'RLJ ENTERTAINMENT': 730, 'RLJ': 730, 'ROADRUNNER RECORDS': 731, 'ROADSHOW ENTERTAINMENT': 732, 'ROADSHOW': 732, 'RONE': 733, 'RONIN FLIX': 734, 'ROTANA HOME ENTERTAINMENT': 735, 'ROTANA ENTERTAINMENT': 735, 'ROTANA HOME': 735, 'ROTANA': 735, 'ROUGH TRADE': 736, 'ROUNDER': 737, 'SAFFRON HILL FILMS': 738, 'SAFFRON HILL': 738, 'SAFFRON': 738, 'SAMUEL GOLDWYN FILMS': 739, 'SAMUEL GOLDWYN': 739, 'SAN FRANCISCO SYMPHONY': 740, 'SANDREW METRONOME': 741, 'SAPHRANE': 742, 'SAVOR': 743, 'SCANBOX ENTERTAINMENT': 744, 'SCANBOX': 744, 'SCENIC LABS': 745, 'SCHRÖDERMEDIA': 746, 'SCHRODERMEDIA': 746, 'SCHRODER MEDIA': 746, 'SCORPION RELEASING': 747, 'SCORPION': 747, 'SCREAM TEAM RELEASING': 748, 'SCREAM TEAM': 748, 'SCREEN MEDIA': 749, 'SCREEN': 749, 'SCREENBOUND PICTURES': 750, 'SCREENBOUND': 750, 'SCREENWAVE MEDIA': 751, 'SCREENWAVE': 751, 'SECOND RUN': 752, 'SECOND SIGHT': 753, 'SEEDSMAN GROUP': 754, 'SELECT VIDEO': 755, 'SELECTA VISION': 756, 'SENATOR': 757, 'SENTAI FILMWORKS': 758, 'SENTAI': 758, 'SEVEN7': 759, 'SEVERIN FILMS': 760, 'SEVERIN': 760, 'SEVILLE': 761, 'SEYONS ENTERTAINMENT': 762, 'SEYONS': 762, 'SF STUDIOS': 763, 'SGL ENTERTAINMENT': 764, 'SGL': 764, 'SHAMELESS': 765, 'SHAMROCK MEDIA': 766, 'SHAMROCK': 766, 'SHANGHAI EPIC MUSIC ENTERTAINMENT': 767, 'SHANGHAI EPIC ENTERTAINMENT': 767, 'SHANGHAI EPIC MUSIC': 767, 'SHANGHAI MUSIC ENTERTAINMENT': 767, 'SHANGHAI ENTERTAINMENT': 767, 'SHANGHAI MUSIC': 767, 'SHANGHAI': 767, 'SHEMAROO': 768, 'SHOCHIKU': 769, 'SHOCK': 770, 'SHOGAKU KAN': 771, 'SHOUT FACTORY': 772, 'SHOUT! FACTORY': 772, 'SHOUT': 772, 'SHOUT!': 772, 'SHOWBOX': 773, 'SHOWTIME ENTERTAINMENT': 774, 'SHOWTIME': 774, 'SHRIEK SHOW': 775, 'SHUDDER': 776, 'SIDONIS': 777, 'SIDONIS CALYSTA': 778, 'SIGNAL ONE ENTERTAINMENT': 779, 'SIGNAL ONE': 779, 'SIGNATURE ENTERTAINMENT': 780, 'SIGNATURE': 780, 'SILVER VISION': 781, 'SINISTER FILM': 782, 'SINISTER': 782, 'SIREN VISUAL ENTERTAINMENT': 783, 'SIREN VISUAL': 783, 'SIREN ENTERTAINMENT': 783, 'SIREN': 783, 'SKANI': 784, 'SKY DIGI': 785, 'SLASHER // VIDEO': 786, 'SLASHER / VIDEO': 786, 'SLASHER VIDEO': 786, 'SLASHER': 786, 'SLOVAK FILM INSTITUTE': 787, 'SLOVAK FILM': 787, - 'SFI': 787, 'SM LIFE DESIGN GROUP': 788, 'SMOOTH PICTURES': 789, 'SMOOTH': 789, 'SNAPPER MUSIC': 790, 'SNAPPER': 790, 'SODA PICTURES': 791, 'SODA': 791, 'SONO LUMINUS': 792, 'SONY MUSIC': 793, 'SONY PICTURES': 794, 'SONY': 794, 'SONY PICTURES CLASSICS': 795, 'SONY CLASSICS': 795, 'SOUL MEDIA': 796, 'SOUL': 796, 'SOULFOOD MUSIC DISTRIBUTION': 797, 'SOULFOOD DISTRIBUTION': 797, 'SOULFOOD MUSIC': 797, 'SOULFOOD': 797, 'SOYUZ': 798, 'SPECTRUM': 799, - 'SPENTZOS FILM': 800, 'SPENTZOS': 800, 'SPIRIT ENTERTAINMENT': 801, 'SPIRIT': 801, 'SPIRIT MEDIA GMBH': 802, 'SPIRIT MEDIA': 802, 'SPLENDID ENTERTAINMENT': 803, 'SPLENDID FILM': 804, 'SPO': 805, 'SQUARE ENIX': 806, 'SRI BALAJI VIDEO': 807, 'SRI BALAJI': 807, 'SRI': 807, 'SRI VIDEO': 807, 'SRS CINEMA': 808, 'SRS': 808, 'SSO RECORDINGS': 809, 'SSO': 809, 'ST2 MUSIC': 810, 'ST2': 810, 'STAR MEDIA ENTERTAINMENT': 811, 'STAR ENTERTAINMENT': 811, 'STAR MEDIA': 811, 'STAR': 811, 'STARLIGHT': 812, 'STARZ / ANCHOR BAY': 813, 'STARZ ANCHOR BAY': 813, 'STARZ': 813, 'ANCHOR BAY': 813, 'STER KINEKOR': 814, 'STERLING ENTERTAINMENT': 815, 'STERLING': 815, 'STINGRAY': 816, 'STOCKFISCH RECORDS': 817, 'STOCKFISCH': 817, 'STRAND RELEASING': 818, 'STRAND': 818, 'STUDIO 4K': 819, 'STUDIO CANAL': 820, 'STUDIO GHIBLI': 821, 'GHIBLI': 821, 'STUDIO HAMBURG ENTERPRISES': 822, 'HAMBURG ENTERPRISES': 822, 'STUDIO HAMBURG': 822, 'HAMBURG': 822, 'STUDIO S': 823, 'SUBKULTUR ENTERTAINMENT': 824, 'SUBKULTUR': 824, 'SUEVIA FILMS': 825, 'SUEVIA': 825, 'SUMMIT ENTERTAINMENT': 826, 'SUMMIT': 826, 'SUNFILM ENTERTAINMENT': 827, 'SUNFILM': 827, 'SURROUND RECORDS': 828, 'SURROUND': 828, 'SVENSK FILMINDUSTRI': 829, 'SVENSK': 829, 'SWEN FILMES': 830, 'SWEN FILMS': 830, 'SWEN': 830, 'SYNAPSE FILMS': 831, 'SYNAPSE': 831, 'SYNDICADO': 832, 'SYNERGETIC': 833, 'T- SERIES': 834, 'T-SERIES': 834, 'T SERIES': 834, 'TSERIES': 834, 'T.V.P.': 835, 'TVP': 835, 'TACET RECORDS': 836, 'TACET': 836, 'TAI SENG': 837, 'TAI SHENG': 838, 'TAKEONE': 839, 'TAKESHOBO': 840, 'TAMASA DIFFUSION': 841, 'TC ENTERTAINMENT': 842, 'TC': 842, 'TDK': 843, 'TEAM MARKETING': 844, 'TEATRO REAL': 845, 'TEMA DISTRIBUCIONES': 846, 'TEMPE DIGITAL': 847, 'TF1 VIDÉO': 848, 'TF1 VIDEO': 848, 'TF1': 848, 'THE BLU': 849, 'BLU': 849, 'THE ECSTASY OF FILMS': 850, 'THE FILM DETECTIVE': 851, 'FILM DETECTIVE': 851, 'THE JOKERS': 852, 'JOKERS': 852, 'THE ON': 853, 'ON': 853, 'THIMFILM': 854, 'THIM FILM': 854, 'THIM': 854, 'THIRD WINDOW FILMS': 855, 'THIRD WINDOW': 855, '3RD WINDOW FILMS': 855, '3RD WINDOW': 855, 'THUNDERBEAN ANIMATION': 856, 'THUNDERBEAN': 856, 'THUNDERBIRD RELEASING': 857, 'THUNDERBIRD': 857, 'TIBERIUS FILM': 858, 'TIME LIFE': 859, 'TIMELESS MEDIA GROUP': 860, 'TIMELESS MEDIA': 860, 'TIMELESS GROUP': 860, 'TIMELESS': 860, 'TLA RELEASING': 861, 'TLA': 861, 'TOBIS FILM': 862, 'TOBIS': 862, 'TOEI': 863, 'TOHO': 864, 'TOKYO SHOCK': 865, 'TOKYO': 865, 'TONPOOL MEDIEN GMBH': 866, 'TONPOOL MEDIEN': 866, 'TOPICS ENTERTAINMENT': 867, 'TOPICS': 867, 'TOUCHSTONE PICTURES': 868, 'TOUCHSTONE': 868, 'TRANSMISSION FILMS': 869, 'TRANSMISSION': 869, 'TRAVEL VIDEO STORE': 870, 'TRIART': 871, 'TRIGON FILM': 872, 'TRIGON': 872, 'TRINITY HOME ENTERTAINMENT': 873, 'TRINITY ENTERTAINMENT': 873, 'TRINITY HOME': 873, 'TRINITY': 873, 'TRIPICTURES': 874, 'TRI-PICTURES': 874, 'TRI PICTURES': 874, 'TROMA': 875, 'TURBINE MEDIEN': 876, 'TURTLE RECORDS': 877, 'TURTLE': 877, 'TVA FILMS': 878, 'TVA': 878, 'TWILIGHT TIME': 879, 'TWILIGHT': 879, 'TT': 879, 'TWIN CO., LTD.': 880, 'TWIN CO, LTD.': 880, 'TWIN CO., LTD': 880, 'TWIN CO, LTD': 880, 'TWIN CO LTD': 880, 'TWIN LTD': 880, 'TWIN CO.': 880, 'TWIN CO': 880, 'TWIN': 880, 'UCA': 881, 'UDR': 882, 'UEK': 883, 'UFA/DVD': 884, 'UFA DVD': 884, 'UFADVD': 884, 'UGC PH': 885, 'ULTIMATE3DHEAVEN': 886, 'ULTRA': 887, 'UMBRELLA ENTERTAINMENT': 888, 'UMBRELLA': 888, 'UMC': 889, "UNCORK'D ENTERTAINMENT": 890, 'UNCORKD ENTERTAINMENT': 890, 'UNCORK D ENTERTAINMENT': 890, "UNCORK'D": 890, 'UNCORK D': 890, 'UNCORKD': 890, 'UNEARTHED FILMS': 891, 'UNEARTHED': 891, 'UNI DISC': 892, 'UNIMUNDOS': 893, 'UNITEL': 894, 'UNIVERSAL MUSIC': 895, 'UNIVERSAL SONY PICTURES HOME ENTERTAINMENT': 896, 'UNIVERSAL SONY PICTURES ENTERTAINMENT': 896, 'UNIVERSAL SONY PICTURES HOME': 896, 'UNIVERSAL SONY PICTURES': 896, 'UNIVERSAL HOME ENTERTAINMENT': - 896, 'UNIVERSAL ENTERTAINMENT': 896, 'UNIVERSAL HOME': 896, 'UNIVERSAL STUDIOS': 897, 'UNIVERSAL': 897, 'UNIVERSE LASER & VIDEO CO.': 898, 'UNIVERSE LASER AND VIDEO CO.': 898, 'UNIVERSE LASER & VIDEO CO': 898, 'UNIVERSE LASER AND VIDEO CO': 898, 'UNIVERSE LASER CO.': 898, 'UNIVERSE LASER CO': 898, 'UNIVERSE LASER': 898, 'UNIVERSUM FILM': 899, 'UNIVERSUM': 899, 'UTV': 900, 'VAP': 901, 'VCI': 902, 'VENDETTA FILMS': 903, 'VENDETTA': 903, 'VERSÁTIL HOME VIDEO': 904, 'VERSÁTIL VIDEO': 904, 'VERSÁTIL HOME': 904, 'VERSÁTIL': 904, 'VERSATIL HOME VIDEO': 904, 'VERSATIL VIDEO': 904, 'VERSATIL HOME': 904, 'VERSATIL': 904, 'VERTICAL ENTERTAINMENT': 905, 'VERTICAL': 905, 'VÉRTICE 360º': 906, 'VÉRTICE 360': 906, 'VERTICE 360o': 906, 'VERTICE 360': 906, 'VERTIGO BERLIN': 907, 'VÉRTIGO FILMS': 908, 'VÉRTIGO': 908, 'VERTIGO FILMS': 908, 'VERTIGO': 908, 'VERVE PICTURES': 909, 'VIA VISION ENTERTAINMENT': 910, 'VIA VISION': 910, 'VICOL ENTERTAINMENT': 911, 'VICOL': 911, 'VICOM': 912, 'VICTOR ENTERTAINMENT': 913, 'VICTOR': 913, 'VIDEA CDE': 914, 'VIDEO FILM EXPRESS': 915, 'VIDEO FILM': 915, 'VIDEO EXPRESS': 915, 'VIDEO MUSIC, INC.': 916, 'VIDEO MUSIC, INC': 916, 'VIDEO MUSIC INC.': 916, 'VIDEO MUSIC INC': 916, 'VIDEO MUSIC': 916, 'VIDEO SERVICE CORP.': 917, 'VIDEO SERVICE CORP': 917, 'VIDEO SERVICE': 917, 'VIDEO TRAVEL': 918, 'VIDEOMAX': 919, 'VIDEO MAX': 919, 'VII PILLARS ENTERTAINMENT': 920, 'VII PILLARS': 920, 'VILLAGE FILMS': 921, 'VINEGAR SYNDROME': 922, 'VINEGAR': 922, 'VS': 922, 'VINNY MOVIES': 923, 'VINNY': 923, 'VIRGIL FILMS & ENTERTAINMENT': 924, 'VIRGIL FILMS AND ENTERTAINMENT': 924, 'VIRGIL ENTERTAINMENT': 924, 'VIRGIL FILMS': 924, 'VIRGIL': 924, 'VIRGIN RECORDS': 925, 'VIRGIN': 925, 'VISION FILMS': 926, 'VISION': 926, 'VISUAL ENTERTAINMENT GROUP': 927, 'VISUAL GROUP': 927, 'VISUAL ENTERTAINMENT': 927, 'VISUAL': 927, 'VIVENDI VISUAL ENTERTAINMENT': 928, 'VIVENDI VISUAL': 928, 'VIVENDI': 928, 'VIZ PICTURES': 929, 'VIZ': 929, 'VLMEDIA': 930, 'VL MEDIA': 930, 'VL': 930, 'VOLGA': 931, 'VVS FILMS': 932, - 'VVS': 932, 'VZ HANDELS GMBH': 933, 'VZ HANDELS': 933, 'WARD RECORDS': 934, 'WARD': 934, 'WARNER BROS.': 935, 'WARNER BROS': 935, 'WARNER ARCHIVE': 935, 'WARNER ARCHIVE COLLECTION': 935, 'WAC': 935, 'WARNER': 935, 'WARNER MUSIC': 936, 'WEA': 937, 'WEINSTEIN COMPANY': 938, 'WEINSTEIN': 938, 'WELL GO USA': 939, 'WELL GO': 939, 'WELTKINO FILMVERLEIH': 940, 'WEST VIDEO': 941, 'WEST': 941, 'WHITE PEARL MOVIES': 942, 'WHITE PEARL': 942, + await descfile.write(desc) + # Handle single disc case + if len(discs) == 1: + each = discs[0] + if each['type'] == "DVD": + await descfile.write("[center]") + await descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code]{each['vob_mi']}[/code][/spoiler]\n\n") + await descfile.write("[/center]") + if screenheader is not None: + await descfile.write(screenheader + '\n') + await descfile.write("[center]") + for img_index in range(len(images[:int(meta['screens'])])): + web_url = images[img_index]['web_url'] + raw_url = images[img_index]['raw_url'] + await descfile.write(f"[url={web_url}][img={self.config['DEFAULT'].get('thumbnail_size', '350')}]{raw_url}[/img][/url] ") + if screensPerRow and (img_index + 1) % screensPerRow == 0: + await descfile.write("\n") + await descfile.write("[/center]") + if each['type'] == "BDMV": + bdinfo_keys = [key for key in each if key.startswith("bdinfo")] + if len(bdinfo_keys) > 1: + if 'retry_count' not in meta: + meta['retry_count'] = 0 + + for i, key in enumerate(bdinfo_keys[1:], start=1): # Skip the first bdinfo + new_images_key = f'new_images_playlist_{i}' + bdinfo = each[key] + edition = bdinfo.get("edition", "Unknown Edition") + + # Find the corresponding summary for this bdinfo + summary_key = f"summary_{i}" if i > 0 else "summary" + summary = each.get(summary_key, "No summary available") + + # Check for saved images first + if pack_images_data and 'keys' in pack_images_data and new_images_key in pack_images_data['keys']: + saved_images = pack_images_data['keys'][new_images_key]['images'] + if saved_images: + if meta['debug']: + console.print(f"[yellow]Using saved images from pack_image_links.json for {new_images_key}") + + meta[new_images_key] = [] + for img in saved_images: + meta[new_images_key].append({ + 'img_url': img.get('img_url', ''), + 'raw_url': img.get('raw_url', ''), + 'web_url': img.get('web_url', '') + }) + + if new_images_key in meta and meta[new_images_key]: + await descfile.write("[center]\n\n") + # Use the summary corresponding to the current bdinfo + await descfile.write(f"[spoiler={edition}][code]{summary}[/code][/spoiler]\n\n") + if meta['debug']: + console.print("[yellow]Using original uploaded images for first disc") + await descfile.write("[center]") + for img in meta[new_images_key]: + web_url = img['web_url'] + raw_url = img['raw_url'] + image_str = f"[url={web_url}][img={thumb_size}]{raw_url}[/img][/url] " + await descfile.write(image_str) + await descfile.write("[/center]\n\n") + else: + await descfile.write("[center]\n\n") + # Use the summary corresponding to the current bdinfo + await descfile.write(f"[spoiler={edition}][code]{summary}[/code][/spoiler]\n\n") + await descfile.write("[/center]\n\n") + meta['retry_count'] += 1 + meta[new_images_key] = [] + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"PLAYLIST_{i}-*.png") + if not new_screens: + use_vs = meta.get('vapoursynth', False) + try: + await disc_screenshots(meta, f"PLAYLIST_{i}", bdinfo, meta['uuid'], meta['base_dir'], use_vs, [], meta.get('ffdebug', False), multi_screens, True) + except Exception as e: + print(f"Error during BDMV screenshot capture: {e}") + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"PLAYLIST_{i}-*.png") + if new_screens and not meta.get('skip_imghost_upload', False): + uploaded_images, _ = await upload_screens(meta, multi_screens, 1, 0, multi_screens, new_screens, {new_images_key: meta[new_images_key]}) + if uploaded_images and not meta.get('skip_imghost_upload', False): + await self.save_image_links(meta, new_images_key, uploaded_images) + for img in uploaded_images: + meta[new_images_key].append({ + 'img_url': img['img_url'], + 'raw_url': img['raw_url'], + 'web_url': img['web_url'] + }) + + await descfile.write("[center]") + for img in uploaded_images: + web_url = img['web_url'] + raw_url = img['raw_url'] + image_str = f"[url={web_url}][img={thumb_size}]{raw_url}[/img][/url] " + await descfile.write(image_str) + await descfile.write("[/center]\n\n") + + meta_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json" + async with aiofiles.open(meta_filename, 'w') as f: + await f.write(json.dumps(meta, indent=4)) + + # Handle multiple discs case + elif len(discs) > 1: + # Initialize retry_count if not already set + if 'retry_count' not in meta: + meta['retry_count'] = 0 + + total_discs_to_process = min(len(discs), process_limit) + processed_count = 0 + if multi_screens != 0: + console.print("[cyan]Processing screenshots for packed content (multiScreens)[/cyan]") + console.print(f"[cyan]{total_discs_to_process} files (processLimit)[/cyan]") + + for i, each in enumerate(discs): + # Set a unique key per disc for managing images + new_images_key = f'new_images_disc_{i}' + + if i == 0: + await descfile.write("[center]") + if each['type'] == "BDMV": + await descfile.write(f"{each.get('name', 'BDINFO')}\n\n") + elif each['type'] == "DVD": + await descfile.write(f"{each['name']}:\n") + await descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code]{each['vob_mi']}[/code][/spoiler]") + await descfile.write(f"[spoiler={os.path.basename(each['ifo'])}][code]{each['ifo_mi']}[/code][/spoiler]\n\n") + # For the first disc, use images from `meta['image_list']` and add screenheader if applicable + if meta['debug']: + console.print("[yellow]Using original uploaded images for first disc") + if screenheader is not None: + await descfile.write("[/center]\n\n") + await descfile.write(screenheader + '\n') + await descfile.write("[center]") + for img_index in range(len(images[:int(meta['screens'])])): + web_url = images[img_index]['web_url'] + raw_url = images[img_index]['raw_url'] + image_str = f"[url={web_url}][img={thumb_size}]{raw_url}[/img][/url]" + await descfile.write(image_str) + if screensPerRow and (img_index + 1) % screensPerRow == 0: + await descfile.write("\n") + await descfile.write("[/center]\n\n") + else: + if multi_screens != 0: + processed_count += 1 + disc_name = each.get('name', f"Disc {i}") + print(f"\rProcessing disc {processed_count}/{total_discs_to_process}: {disc_name[:40]}{'...' if len(disc_name) > 40 else ''}", end="", flush=True) + # Check if screenshots exist for the current disc key + # Check for saved images first + if pack_images_data and 'keys' in pack_images_data and new_images_key in pack_images_data['keys']: + saved_images = pack_images_data['keys'][new_images_key]['images'] + if saved_images: + if meta['debug']: + console.print(f"[yellow]Using saved images from pack_image_links.json for {new_images_key}") + + meta[new_images_key] = [] + for img in saved_images: + meta[new_images_key].append({ + 'img_url': img.get('img_url', ''), + 'raw_url': img.get('raw_url', ''), + 'web_url': img.get('web_url', '') + }) + if new_images_key in meta and meta[new_images_key]: + if meta['debug']: + console.print(f"[yellow]Found needed image URLs for {new_images_key}") + await descfile.write("[center]") + if each['type'] == "BDMV": + await descfile.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]\n\n") + elif each['type'] == "DVD": + await descfile.write(f"{each['name']}:\n") + await descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code]{each['vob_mi']}[/code][/spoiler] ") + await descfile.write(f"[spoiler={os.path.basename(each['ifo'])}][code]{each['ifo_mi']}[/code][/spoiler]\n\n") + await descfile.write("[/center]\n\n") + # Use existing URLs from meta to write to descfile + await descfile.write("[center]") + for img in meta[new_images_key]: + web_url = img['web_url'] + raw_url = img['raw_url'] + image_str = f"[url={web_url}][img={thumb_size}]{raw_url}[/img][/url]" + await descfile.write(image_str) + await descfile.write("[/center]\n\n") + else: + # Increment retry_count for tracking but use unique disc keys for each disc + meta['retry_count'] += 1 + meta[new_images_key] = [] + await descfile.write("[center]") + if each['type'] == "BDMV": + await descfile.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]\n\n") + elif each['type'] == "DVD": + await descfile.write(f"{each['name']}:\n") + await descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code]{each['vob_mi']}[/code][/spoiler] ") + await descfile.write(f"[spoiler={os.path.basename(each['ifo'])}][code]{each['ifo_mi']}[/code][/spoiler]\n\n") + await descfile.write("[/center]\n\n") + # Check if new screenshots already exist before running prep.screenshots + if each['type'] == "BDMV": + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + elif each['type'] == "DVD": + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"{meta['discs'][i]['name']}-*.png") + if not new_screens: + if meta['debug']: + console.print(f"[yellow]No new screens for {new_images_key}; creating new screenshots") + # Run prep.screenshots if no screenshots are present + if each['type'] == "BDMV": + use_vs = meta.get('vapoursynth', False) + try: + await disc_screenshots(meta, f"FILE_{i}", each['bdinfo'], meta['uuid'], meta['base_dir'], use_vs, [], meta.get('ffdebug', False), multi_screens, True) + except Exception as e: + print(f"Error during BDMV screenshot capture: {e}") + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + if each['type'] == "DVD": + try: + await dvd_screenshots(meta, i, multi_screens, True) + except Exception as e: + print(f"Error during DVD screenshot capture: {e}") + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"{meta['discs'][i]['name']}-*.png") + + if new_screens and not meta.get('skip_imghost_upload', False): + uploaded_images, _ = await upload_screens(meta, multi_screens, 1, 0, multi_screens, new_screens, {new_images_key: meta[new_images_key]}) + if uploaded_images and not meta.get('skip_imghost_upload', False): + await self.save_image_links(meta, new_images_key, uploaded_images) + # Append each uploaded image's data to `meta[new_images_key]` + for img in uploaded_images: + meta[new_images_key].append({ + 'img_url': img['img_url'], + 'raw_url': img['raw_url'], + 'web_url': img['web_url'] + }) + + # Write new URLs to descfile + await descfile.write("[center]") + for img in uploaded_images: + web_url = img['web_url'] + raw_url = img['raw_url'] + image_str = f"[url={web_url}][img={thumb_size}]{raw_url}[/img][/url]" + await descfile.write(image_str) + await descfile.write("[/center]\n\n") + + # Save the updated meta to `meta.json` after upload + meta_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json" + async with aiofiles.open(meta_filename, 'w') as f: + await f.write(json.dumps(meta, indent=4)) + console.print() + + # Handle single file case + if len(filelist) == 1: + if meta.get('comparison') and meta.get('comparison_groups'): + await descfile.write("[center]") + comparison_groups = meta.get('comparison_groups', {}) + sorted_group_indices = sorted(comparison_groups.keys(), key=lambda x: int(x)) + + comp_sources = [] + for group_idx in sorted_group_indices: + group_data = comparison_groups[group_idx] + group_name = group_data.get('name', f'Group {group_idx}') + comp_sources.append(group_name) + + sources_string = ", ".join(comp_sources) + await descfile.write(f"[comparison={sources_string}]\n") + + images_per_group = min([ + len(comparison_groups[idx].get('urls', [])) + for idx in sorted_group_indices + ]) + + for img_idx in range(images_per_group): + for group_idx in sorted_group_indices: + group_data = comparison_groups[group_idx] + urls = group_data.get('urls', []) + if img_idx < len(urls): + img_url = urls[img_idx].get('raw_url', '') + if img_url: + await descfile.write(f"{img_url}\n") + + await descfile.write("[/comparison][/center]\n\n") + + if screenheader is not None: + await descfile.write(screenheader + '\n') + await descfile.write("[center]") + for img_index in range(len(images[:int(meta['screens'])])): + web_url = images[img_index]['web_url'] + raw_url = images[img_index]['raw_url'] + await descfile.write(f"[url={web_url}][img={self.config['DEFAULT'].get('thumbnail_size', '350')}]{raw_url}[/img][/url] ") + if screensPerRow and (img_index + 1) % screensPerRow == 0: + await descfile.write("\n") + await descfile.write("[/center]") + + # Handle multiple files case + # Initialize character counter + char_count = 0 + max_char_limit = char_limit # Character limit + other_files_spoiler_open = False # Track if "Other files" spoiler has been opened + total_files_to_process = min(len(filelist), process_limit) + processed_count = 0 + if multi_screens != 0 and total_files_to_process > 1: + console.print("[cyan]Processing screenshots for packed content (multiScreens)[/cyan]") + console.print(f"[cyan]{total_files_to_process} files (processLimit)[/cyan]") + + # First Pass: Create and Upload Images for Each File + for i, file in enumerate(filelist): + if i >= process_limit: + # console.print("[yellow]Skipping processing more files as they exceed the process limit.") + continue + if multi_screens != 0: + if total_files_to_process > 1: + processed_count += 1 + filename = os.path.basename(file) + print(f"\rProcessing file {processed_count}/{total_files_to_process}: {filename[:40]}{'...' if len(filename) > 40 else ''}", end="", flush=True) + if i > 0: + new_images_key = f'new_images_file_{i}' + # Check for saved images first + if pack_images_data and 'keys' in pack_images_data and new_images_key in pack_images_data['keys']: + saved_images = pack_images_data['keys'][new_images_key]['images'] + if saved_images: + if meta['debug']: + console.print(f"[yellow]Using saved images from pack_image_links.json for {new_images_key}") + + meta[new_images_key] = [] + for img in saved_images: + meta[new_images_key].append({ + 'img_url': img.get('img_url', ''), + 'raw_url': img.get('raw_url', ''), + 'web_url': img.get('web_url', '') + }) + if new_images_key not in meta or not meta[new_images_key]: + meta[new_images_key] = [] + # Proceed with image generation if not already present + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + + # If no screenshots exist, create them + if not new_screens: + if meta['debug']: + console.print(f"[yellow]No existing screenshots for {new_images_key}; generating new ones.") + try: + await screenshots(file, f"FILE_{i}", meta['uuid'], meta['base_dir'], meta, multi_screens, True, None) + await asyncio.sleep(0.1) + except Exception as e: + print(f"Error during generic screenshot capture: {e}") + + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + + # Upload generated screenshots + if new_screens and not meta.get('skip_imghost_upload', False): + uploaded_images, _ = await upload_screens(meta, multi_screens, 1, 0, multi_screens, new_screens, {new_images_key: meta[new_images_key]}) + if uploaded_images and not meta.get('skip_imghost_upload', False): + await self.save_image_links(meta, new_images_key, uploaded_images) + for img in uploaded_images: + meta[new_images_key].append({ + 'img_url': img['img_url'], + 'raw_url': img['raw_url'], + 'web_url': img['web_url'] + }) + + await asyncio.sleep(0.1) + + await asyncio.sleep(0.05) + + # Save updated meta + meta_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json" + async with aiofiles.open(meta_filename, 'w') as f: + await f.write(json.dumps(meta, indent=4)) + await asyncio.sleep(0.1) + + # Second Pass: Process MediaInfo and Write Descriptions + if len(filelist) > 1: + for i, file in enumerate(filelist): + if i >= process_limit: + continue + # Extract filename directly from the file path + filename = os.path.splitext(os.path.basename(file.strip()))[0].replace('[', '').replace(']', '') + + # If we are beyond the file limit, add all further files in a spoiler + if multi_screens != 0: + if i >= file_limit: + if not other_files_spoiler_open: + await descfile.write("[center][spoiler=Other files]\n") + char_count += len("[center][spoiler=Other files]\n") + other_files_spoiler_open = True + + # Write filename in BBCode format with MediaInfo in spoiler if not the first file + if multi_screens != 0: + if i > 0 and char_count < max_char_limit: + mi_dump = MediaInfo.parse(file, output="STRING", full=False, mediainfo_options={'inform_version': '1'}) + parsed_mediainfo = self.parser.parse_mediainfo(mi_dump) + formatted_bbcode = self.parser.format_bbcode(parsed_mediainfo) + await descfile.write(f"[center][spoiler={filename}]{formatted_bbcode}[/spoiler][/center]\n") + char_count += len(f"[center][spoiler={filename}]{formatted_bbcode}[/spoiler][/center]\n") + else: + if i == 0 and images and screenheader is not None: + await descfile.write(screenheader + '\n') + char_count += len(screenheader + '\n') + await descfile.write(f"[center]{filename}\n[/center]\n") + char_count += len(f"[center]{filename}\n[/center]\n") + + # Write images if they exist + new_images_key = f'new_images_file_{i}' + if i == 0: # For the first file, use 'image_list' key and add screenheader if applicable + if images: + if screenheader is not None: + await descfile.write(screenheader + '\n') + char_count += len(screenheader + '\n') + await descfile.write("[center]") + char_count += len("[center]") + for img_index in range(len(images)): + web_url = images[img_index]['web_url'] + raw_url = images[img_index]['raw_url'] + image_str = f"[url={web_url}][img={thumb_size}]{raw_url}[/img][/url] " + await descfile.write(image_str) + char_count += len(image_str) + if screensPerRow and (img_index + 1) % screensPerRow == 0: + await descfile.write("\n") + await descfile.write("[/center]\n\n") + char_count += len("[/center]\n\n") + elif multi_screens != 0: + if new_images_key in meta and meta[new_images_key]: + await descfile.write("[center]") + char_count += len("[center]") + for img in meta[new_images_key]: + web_url = img['web_url'] + raw_url = img['raw_url'] + image_str = f"[url={web_url}][img={thumb_size}]{raw_url}[/img][/url] " + await descfile.write(image_str) + char_count += len(image_str) + await descfile.write("[/center]\n\n") + char_count += len("[/center]\n\n") + + if other_files_spoiler_open: + await descfile.write("[/spoiler][/center]\n") + char_count += len("[/spoiler][/center]\n") + + if char_count >= 1 and meta['debug']: + console.print(f"[yellow]Total characters written to description: {char_count}") + if total_files_to_process > 1: + console.print() + + # Append signature if provided + if signature: + await descfile.write(signature) + + return + + async def save_image_links(self, meta, image_key, image_list=None): + if image_list is None: + console.print("[yellow]No image links to save.[/yellow]") + return None + + output_dir = os.path.join(meta['base_dir'], "tmp", meta['uuid']) + os.makedirs(output_dir, exist_ok=True) + output_file = os.path.join(output_dir, "pack_image_links.json") + + # Load existing data if the file exists + existing_data = {} + if os.path.exists(output_file): + try: + with open(output_file, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + except Exception as e: + console.print(f"[yellow]Warning: Could not load existing image data: {str(e)}[/yellow]") + + # Create data structure if it doesn't exist yet + if not existing_data: + existing_data = { + "keys": {}, + "total_count": 0 + } + + # Update the data with the new images under the specific key + if image_key not in existing_data["keys"]: + existing_data["keys"][image_key] = { + "count": 0, + "images": [] + } + + # Add new images to the specific key + for idx, img in enumerate(image_list): + image_entry = { + "index": existing_data["keys"][image_key]["count"] + idx, + "raw_url": img.get("raw_url", ""), + "web_url": img.get("web_url", ""), + "img_url": img.get("img_url", ""), + } + existing_data["keys"][image_key]["images"].append(image_entry) + + # Update counts + existing_data["keys"][image_key]["count"] = len(existing_data["keys"][image_key]["images"]) + existing_data["total_count"] = sum(key_data["count"] for key_data in existing_data["keys"].values()) + + try: + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(existing_data, f, indent=2) + + if meta['debug']: + console.print(f"[green]Saved {len(image_list)} new images for key '{image_key}' (total: {existing_data['total_count']}):[/green]") + console.print(f"[blue] - JSON: {output_file}[/blue]") + + return output_file + except Exception as e: + console.print(f"[bold red]Error saving image links: {e}[/bold red]") + return None + + async def unit3d_region_ids(self, region, reverse=False, region_id=None): + region_map = { + 'AFG': 1, 'AIA': 2, 'ALA': 3, 'ALG': 4, 'AND': 5, 'ANG': 6, 'ARG': 7, 'ARM': 8, 'ARU': 9, + 'ASA': 10, 'ATA': 11, 'ATF': 12, 'ATG': 13, 'AUS': 14, 'AUT': 15, 'AZE': 16, 'BAH': 17, + 'BAN': 18, 'BDI': 19, 'BEL': 20, 'BEN': 21, 'BER': 22, 'BES': 23, 'BFA': 24, 'BHR': 25, + 'BHU': 26, 'BIH': 27, 'BLM': 28, 'BLR': 29, 'BLZ': 30, 'BOL': 31, 'BOT': 32, 'BRA': 33, + 'BRB': 34, 'BRU': 35, 'BVT': 36, 'CAM': 37, 'CAN': 38, 'CAY': 39, 'CCK': 40, 'CEE': 41, + 'CGO': 42, 'CHA': 43, 'CHI': 44, 'CHN': 45, 'CIV': 46, 'CMR': 47, 'COD': 48, 'COK': 49, + 'COL': 50, 'COM': 51, 'CPV': 52, 'CRC': 53, 'CRO': 54, 'CTA': 55, 'CUB': 56, 'CUW': 57, + 'CXR': 58, 'CYP': 59, 'DJI': 60, 'DMA': 61, 'DOM': 62, 'ECU': 63, 'EGY': 64, 'ENG': 65, + 'EQG': 66, 'ERI': 67, 'ESH': 68, 'ESP': 69, 'ETH': 70, 'FIJ': 71, 'FLK': 72, 'FRA': 73, + 'FRO': 74, 'FSM': 75, 'GAB': 76, 'GAM': 77, 'GBR': 78, 'GEO': 79, 'GER': 80, 'GGY': 81, + 'GHA': 82, 'GIB': 83, 'GLP': 84, 'GNB': 85, 'GRE': 86, 'GRL': 87, 'GRN': 88, 'GUA': 89, + 'GUF': 90, 'GUI': 91, 'GUM': 92, 'GUY': 93, 'HAI': 94, 'HKG': 95, 'HMD': 96, 'HON': 97, + 'HUN': 98, 'IDN': 99, 'IMN': 100, 'IND': 101, 'IOT': 102, 'IRL': 103, 'IRN': 104, 'IRQ': 105, + 'ISL': 106, 'ISR': 107, 'ITA': 108, 'JAM': 109, 'JEY': 110, 'JOR': 111, 'JPN': 112, 'KAZ': 113, + 'KEN': 114, 'KGZ': 115, 'KIR': 116, 'KNA': 117, 'KOR': 118, 'KSA': 119, 'KUW': 120, 'KVX': 121, + 'LAO': 122, 'LBN': 123, 'LBR': 124, 'LBY': 125, 'LCA': 126, 'LES': 127, 'LIE': 128, 'LKA': 129, + 'LUX': 130, 'MAC': 131, 'MAD': 132, 'MAF': 133, 'MAR': 134, 'MAS': 135, 'MDA': 136, 'MDV': 137, + 'MEX': 138, 'MHL': 139, 'MKD': 140, 'MLI': 141, 'MLT': 142, 'MNG': 143, 'MNP': 144, 'MON': 145, + 'MOZ': 146, 'MRI': 147, 'MSR': 148, 'MTN': 149, 'MTQ': 150, 'MWI': 151, 'MYA': 152, 'MYT': 153, + 'NAM': 154, 'NCA': 155, 'NCL': 156, 'NEP': 157, 'NFK': 158, 'NIG': 159, 'NIR': 160, 'NIU': 161, + 'NLD': 162, 'NOR': 163, 'NRU': 164, 'NZL': 165, 'OMA': 166, 'PAK': 167, 'PAN': 168, 'PAR': 169, + 'PCN': 170, 'PER': 171, 'PHI': 172, 'PLE': 173, 'PLW': 174, 'PNG': 175, 'POL': 176, 'POR': 177, + 'PRK': 178, 'PUR': 179, 'QAT': 180, 'REU': 181, 'ROU': 182, 'RSA': 183, 'RUS': 184, 'RWA': 185, + 'SAM': 186, 'SCO': 187, 'SDN': 188, 'SEN': 189, 'SEY': 190, 'SGS': 191, 'SHN': 192, 'SIN': 193, + 'SJM': 194, 'SLE': 195, 'SLV': 196, 'SMR': 197, 'SOL': 198, 'SOM': 199, 'SPM': 200, 'SRB': 201, + 'SSD': 202, 'STP': 203, 'SUI': 204, 'SUR': 205, 'SWZ': 206, 'SXM': 207, 'SYR': 208, 'TAH': 209, + 'TAN': 210, 'TCA': 211, 'TGA': 212, 'THA': 213, 'TJK': 214, 'TKL': 215, 'TKM': 216, 'TLS': 217, + 'TOG': 218, 'TRI': 219, 'TUN': 220, 'TUR': 221, 'TUV': 222, 'TWN': 223, 'UAE': 224, 'UGA': 225, + 'UKR': 226, 'UMI': 227, 'URU': 228, 'USA': 229, 'UZB': 230, 'VAN': 231, 'VAT': 232, 'VEN': 233, + 'VGB': 234, 'VIE': 235, 'VIN': 236, 'VIR': 237, 'WAL': 238, 'WLF': 239, 'YEM': 240, 'ZAM': 241, + 'ZIM': 242, 'EUR': 243 + } + + if reverse: + # Reverse lookup: Find region code by ID + for code, id_value in region_map.items(): + if id_value == region_id: + return code + return None + else: + # Forward lookup: Find region ID by code + return region_map.get(region, 0) + + async def unit3d_distributor_ids(self, distributor, reverse=False, distributor_id=None): + distributor_map = { + '01 DISTRIBUTION': 1, '100 DESTINATIONS TRAVEL FILM': 2, '101 FILMS': 3, '1FILMS': 4, '2 ENTERTAIN VIDEO': 5, '20TH CENTURY FOX': 6, '2L': 7, '3D CONTENT HUB': 8, '3D MEDIA': 9, '3L FILM': 10, '4DIGITAL': 11, '4DVD': 12, '4K ULTRA HD MOVIES': 13, '4K UHD': 13, '8-FILMS': 14, '84 ENTERTAINMENT': 15, '88 FILMS': 16, '@ANIME': 17, 'ANIME': 17, 'A CONTRACORRIENTE': 18, 'A CONTRACORRIENTE FILMS': 19, 'A&E HOME VIDEO': 20, 'A&E': 20, 'A&M RECORDS': 21, 'A+E NETWORKS': 22, 'A+R': 23, 'A-FILM': 24, 'AAA': 25, 'AB VIDÉO': 26, 'AB VIDEO': 26, 'ABC - (AUSTRALIAN BROADCASTING CORPORATION)': 27, 'ABC': 27, 'ABKCO': 28, 'ABSOLUT MEDIEN': 29, 'ABSOLUTE': 30, 'ACCENT FILM ENTERTAINMENT': 31, 'ACCENTUS': 32, 'ACORN MEDIA': 33, 'AD VITAM': 34, 'ADA': 35, 'ADITYA VIDEOS': 36, 'ADSO FILMS': 37, 'AFM RECORDS': 38, 'AGFA': 39, 'AIX RECORDS': 40, 'ALAMODE FILM': 41, 'ALBA RECORDS': 42, 'ALBANY RECORDS': 43, 'ALBATROS': 44, 'ALCHEMY': 45, 'ALIVE': 46, 'ALL ANIME': 47, 'ALL INTERACTIVE ENTERTAINMENT': 48, 'ALLEGRO': 49, 'ALLIANCE': 50, 'ALPHA MUSIC': 51, 'ALTERDYSTRYBUCJA': 52, 'ALTERED INNOCENCE': 53, 'ALTITUDE FILM DISTRIBUTION': 54, 'ALUCARD RECORDS': 55, 'AMAZING D.C.': 56, 'AMAZING DC': 56, 'AMMO CONTENT': 57, 'AMUSE SOFT ENTERTAINMENT': 58, 'ANCONNECT': 59, 'ANEC': 60, 'ANIMATSU': 61, 'ANIME HOUSE': 62, 'ANIME LTD': 63, 'ANIME WORKS': 64, 'ANIMEIGO': 65, 'ANIPLEX': 66, 'ANOLIS ENTERTAINMENT': 67, 'ANOTHER WORLD ENTERTAINMENT': 68, 'AP INTERNATIONAL': 69, 'APPLE': 70, 'ARA MEDIA': 71, 'ARBELOS': 72, 'ARC ENTERTAINMENT': 73, 'ARP SÉLECTION': 74, 'ARP SELECTION': 74, 'ARROW': 75, 'ART SERVICE': 76, 'ART VISION': 77, 'ARTE ÉDITIONS': 78, 'ARTE EDITIONS': 78, 'ARTE VIDÉO': 79, 'ARTE VIDEO': 79, 'ARTHAUS MUSIK': 80, 'ARTIFICIAL EYE': 81, 'ARTSPLOITATION FILMS': 82, 'ARTUS FILMS': 83, 'ASCOT ELITE HOME ENTERTAINMENT': 84, 'ASIA VIDEO': 85, 'ASMIK ACE': 86, 'ASTRO RECORDS & FILMWORKS': 87, 'ASYLUM': 88, 'ATLANTIC FILM': 89, 'ATLANTIC RECORDS': 90, 'ATLAS FILM': 91, 'AUDIO VISUAL ENTERTAINMENT': 92, 'AURO-3D CREATIVE LABEL': 93, 'AURUM': 94, 'AV VISIONEN': 95, 'AV-JET': 96, 'AVALON': 97, 'AVENTI': 98, 'AVEX TRAX': 99, 'AXIOM': 100, 'AXIS RECORDS': 101, 'AYNGARAN': 102, 'BAC FILMS': 103, 'BACH FILMS': 104, 'BANDAI VISUAL': 105, 'BARCLAY': 106, 'BBC': 107, 'BRITISH BROADCASTING CORPORATION': 107, 'BBI FILMS': 108, 'BBI': 108, 'BCI HOME ENTERTAINMENT': 109, 'BEGGARS BANQUET': 110, 'BEL AIR CLASSIQUES': 111, 'BELGA FILMS': 112, 'BELVEDERE': 113, 'BENELUX FILM DISTRIBUTORS': 114, 'BENNETT-WATT MEDIA': 115, 'BERLIN CLASSICS': 116, 'BERLINER PHILHARMONIKER RECORDINGS': 117, 'BEST ENTERTAINMENT': 118, 'BEYOND HOME ENTERTAINMENT': 119, 'BFI VIDEO': 120, 'BFI': 120, 'BRITISH FILM INSTITUTE': 120, 'BFS ENTERTAINMENT': 121, 'BFS': 121, 'BHAVANI': 122, 'BIBER RECORDS': 123, 'BIG HOME VIDEO': 124, 'BILDSTÖRUNG': 125, 'BILDSTORUNG': 125, 'BILL ZEBUB': 126, 'BIRNENBLATT': 127, 'BIT WEL': 128, 'BLACK BOX': 129, 'BLACK HILL PICTURES': 130, 'BLACK HILL': 130, 'BLACK HOLE RECORDINGS': 131, 'BLACK HOLE': 131, 'BLAQOUT': 132, 'BLAUFIELD MUSIC': 133, 'BLAUFIELD': 133, 'BLOCKBUSTER ENTERTAINMENT': 134, 'BLOCKBUSTER': 134, 'BLU PHASE MEDIA': 135, 'BLU-RAY ONLY': 136, 'BLU-RAY': 136, 'BLURAY ONLY': 136, 'BLURAY': 136, 'BLUE GENTIAN RECORDS': 137, 'BLUE KINO': 138, 'BLUE UNDERGROUND': 139, 'BMG/ARISTA': 140, 'BMG': 140, 'BMGARISTA': 140, 'BMG ARISTA': 140, 'ARISTA': + 140, 'ARISTA/BMG': 140, 'ARISTABMG': 140, 'ARISTA BMG': 140, 'BONTON FILM': 141, 'BONTON': 141, 'BOOMERANG PICTURES': 142, 'BOOMERANG': 142, 'BQHL ÉDITIONS': 143, 'BQHL EDITIONS': 143, 'BQHL': 143, 'BREAKING GLASS': 144, 'BRIDGESTONE': 145, 'BRINK': 146, 'BROAD GREEN PICTURES': 147, 'BROAD GREEN': 147, 'BUSCH MEDIA GROUP': 148, 'BUSCH': 148, 'C MAJOR': 149, 'C.B.S.': 150, 'CAICHANG': 151, 'CALIFÓRNIA FILMES': 152, 'CALIFORNIA FILMES': 152, 'CALIFORNIA': 152, 'CAMEO': 153, 'CAMERA OBSCURA': 154, 'CAMERATA': 155, 'CAMP MOTION PICTURES': 156, 'CAMP MOTION': 156, 'CAPELIGHT PICTURES': 157, 'CAPELIGHT': 157, 'CAPITOL': 159, 'CAPITOL RECORDS': 159, 'CAPRICCI': 160, 'CARGO RECORDS': 161, 'CARLOTTA FILMS': 162, 'CARLOTTA': 162, 'CARLOTA': 162, 'CARMEN FILM': 163, 'CASCADE': 164, 'CATCHPLAY': 165, 'CAULDRON FILMS': 166, 'CAULDRON': 166, 'CBS TELEVISION STUDIOS': 167, 'CBS': 167, 'CCTV': 168, 'CCV ENTERTAINMENT': 169, 'CCV': 169, 'CD BABY': 170, 'CD LAND': 171, 'CECCHI GORI': 172, 'CENTURY MEDIA': 173, 'CHUAN XUN SHI DAI MULTIMEDIA': 174, 'CINE-ASIA': 175, 'CINÉART': 176, 'CINEART': 176, 'CINEDIGM': 177, 'CINEFIL IMAGICA': 178, 'CINEMA EPOCH': 179, 'CINEMA GUILD': 180, 'CINEMA LIBRE STUDIOS': 181, 'CINEMA MONDO': 182, 'CINEMATIC VISION': 183, 'CINEPLOIT RECORDS': 184, 'CINESTRANGE EXTREME': 185, 'CITEL VIDEO': 186, 'CITEL': 186, 'CJ ENTERTAINMENT': 187, 'CJ': 187, 'CLASSIC MEDIA': 188, 'CLASSICFLIX': 189, 'CLASSICLINE': 190, 'CLAUDIO RECORDS': 191, 'CLEAR VISION': 192, 'CLEOPATRA': 193, 'CLOSE UP': 194, 'CMS MEDIA LIMITED': 195, 'CMV LASERVISION': 196, 'CN ENTERTAINMENT': 197, 'CODE RED': 198, 'COHEN MEDIA GROUP': 199, 'COHEN': 199, 'COIN DE MIRE CINÉMA': 200, 'COIN DE MIRE CINEMA': 200, 'COLOSSEO FILM': 201, 'COLUMBIA': 203, 'COLUMBIA PICTURES': 203, 'COLUMBIA/TRI-STAR': 204, 'TRI-STAR': 204, 'COMMERCIAL MARKETING': 205, 'CONCORD MUSIC GROUP': 206, 'CONCORDE VIDEO': 207, 'CONDOR': 208, 'CONSTANTIN FILM': 209, 'CONSTANTIN': 209, 'CONSTANTINO FILMES': 210, 'CONSTANTINO': 210, 'CONSTRUCTIVE MEDIA SERVICE': 211, 'CONSTRUCTIVE': 211, 'CONTENT ZONE': 212, 'CONTENTS GATE': 213, 'COQUEIRO VERDE': 214, 'CORNERSTONE MEDIA': 215, 'CORNERSTONE': 215, 'CP DIGITAL': 216, 'CREST MOVIES': 217, 'CRITERION': 218, 'CRITERION COLLECTION': + 218, 'CC': 218, 'CRYSTAL CLASSICS': 219, 'CULT EPICS': 220, 'CULT FILMS': 221, 'CULT VIDEO': 222, 'CURZON FILM WORLD': 223, 'D FILMS': 224, "D'AILLY COMPANY": 225, 'DAILLY COMPANY': 225, 'D AILLY COMPANY': 225, "D'AILLY": 225, 'DAILLY': 225, 'D AILLY': 225, 'DA CAPO': 226, 'DA MUSIC': 227, "DALL'ANGELO PICTURES": 228, 'DALLANGELO PICTURES': 228, "DALL'ANGELO": 228, 'DALL ANGELO PICTURES': 228, 'DALL ANGELO': 228, 'DAREDO': 229, 'DARK FORCE ENTERTAINMENT': 230, 'DARK FORCE': 230, 'DARK SIDE RELEASING': 231, 'DARK SIDE': 231, 'DAZZLER MEDIA': 232, 'DAZZLER': 232, 'DCM PICTURES': 233, 'DCM': 233, 'DEAPLANETA': 234, 'DECCA': 235, 'DEEPJOY': 236, 'DEFIANT SCREEN ENTERTAINMENT': 237, 'DEFIANT SCREEN': 237, 'DEFIANT': 237, 'DELOS': 238, 'DELPHIAN RECORDS': 239, 'DELPHIAN': 239, 'DELTA MUSIC & ENTERTAINMENT': 240, 'DELTA MUSIC AND ENTERTAINMENT': 240, 'DELTA MUSIC ENTERTAINMENT': 240, 'DELTA MUSIC': 240, 'DELTAMAC CO. LTD.': 241, 'DELTAMAC CO LTD': 241, 'DELTAMAC CO': 241, 'DELTAMAC': 241, 'DEMAND MEDIA': 242, 'DEMAND': 242, 'DEP': 243, 'DEUTSCHE GRAMMOPHON': 244, 'DFW': 245, 'DGM': 246, 'DIAPHANA': 247, 'DIGIDREAMS STUDIOS': 248, 'DIGIDREAMS': 248, 'DIGITAL ENVIRONMENTS': 249, 'DIGITAL': 249, 'DISCOTEK MEDIA': 250, 'DISCOVERY CHANNEL': 251, 'DISCOVERY': 251, 'DISK KINO': 252, 'DISNEY / BUENA VISTA': 253, 'DISNEY': 253, 'BUENA VISTA': 253, 'DISNEY BUENA VISTA': 253, 'DISTRIBUTION SELECT': 254, 'DIVISA': 255, 'DNC ENTERTAINMENT': 256, 'DNC': 256, 'DOGWOOF': 257, 'DOLMEN HOME VIDEO': 258, 'DOLMEN': 258, 'DONAU FILM': 259, 'DONAU': 259, 'DORADO FILMS': 260, 'DORADO': 260, 'DRAFTHOUSE FILMS': 261, 'DRAFTHOUSE': 261, 'DRAGON FILM ENTERTAINMENT': 262, 'DRAGON ENTERTAINMENT': 262, 'DRAGON FILM': 262, 'DRAGON': 262, 'DREAMWORKS': 263, 'DRIVE ON RECORDS': 264, 'DRIVE ON': 264, 'DRIVE-ON': 264, 'DRIVEON': 264, 'DS MEDIA': 265, 'DTP ENTERTAINMENT AG': 266, 'DTP ENTERTAINMENT': 266, 'DTP AG': 266, 'DTP': 266, 'DTS ENTERTAINMENT': 267, 'DTS': 267, 'DUKE MARKETING': 268, 'DUKE VIDEO DISTRIBUTION': 269, 'DUKE': 269, 'DUTCH FILMWORKS': 270, 'DUTCH': 270, 'DVD INTERNATIONAL': 271, 'DVD': 271, 'DYBEX': 272, 'DYNAMIC': 273, 'DYNIT': 274, 'E1 ENTERTAINMENT': 275, 'E1': 275, 'EAGLE ENTERTAINMENT': 276, 'EAGLE HOME ENTERTAINMENT PVT.LTD.': + 277, 'EAGLE HOME ENTERTAINMENT PVTLTD': 277, 'EAGLE HOME ENTERTAINMENT PVT LTD': 277, 'EAGLE HOME ENTERTAINMENT': 277, 'EAGLE PICTURES': 278, 'EAGLE ROCK ENTERTAINMENT': 279, 'EAGLE ROCK': 279, 'EAGLE VISION MEDIA': 280, 'EAGLE VISION': 280, 'EARMUSIC': 281, 'EARTH ENTERTAINMENT': 282, 'EARTH': 282, 'ECHO BRIDGE ENTERTAINMENT': 283, 'ECHO BRIDGE': 283, 'EDEL GERMANY GMBH': 284, 'EDEL GERMANY': 284, 'EDEL RECORDS': 285, 'EDITION TONFILM': 286, 'EDITIONS MONTPARNASSE': 287, 'EDKO FILMS LTD.': 288, 'EDKO FILMS LTD': 288, 'EDKO FILMS': 288, 'EDKO': 288, "EIN'S M&M CO": 289, 'EINS M&M CO': 289, "EIN'S M&M": 289, 'EINS M&M': 289, 'ELEA-MEDIA': 290, 'ELEA MEDIA': 290, 'ELEA': 290, 'ELECTRIC PICTURE': 291, 'ELECTRIC': 291, 'ELEPHANT FILMS': 292, 'ELEPHANT': 292, 'ELEVATION': 293, 'EMI': 294, 'EMON': 295, 'EMS': 296, 'EMYLIA': 297, 'ENE MEDIA': 298, 'ENE': 298, 'ENTERTAINMENT IN VIDEO': 299, 'ENTERTAINMENT IN': 299, 'ENTERTAINMENT ONE': 300, 'ENTERTAINMENT ONE FILMS CANADA INC.': 301, 'ENTERTAINMENT ONE FILMS CANADA INC': 301, 'ENTERTAINMENT ONE FILMS CANADA': 301, 'ENTERTAINMENT ONE CANADA INC': 301, + 'ENTERTAINMENT ONE CANADA': 301, 'ENTERTAINMENTONE': 302, 'EONE': 303, 'EOS': 304, 'EPIC PICTURES': 305, 'EPIC': 305, 'EPIC RECORDS': 306, 'ERATO': 307, 'EROS': 308, 'ESC EDITIONS': 309, 'ESCAPI MEDIA BV': 310, 'ESOTERIC RECORDINGS': 311, 'ESPN FILMS': 312, 'EUREKA ENTERTAINMENT': 313, 'EUREKA': 313, 'EURO PICTURES': 314, 'EURO VIDEO': 315, 'EUROARTS': 316, 'EUROPA FILMES': 317, 'EUROPA': 317, 'EUROPACORP': 318, 'EUROZOOM': 319, 'EXCEL': 320, 'EXPLOSIVE MEDIA': 321, 'EXPLOSIVE': 321, 'EXTRALUCID FILMS': 322, 'EXTRALUCID': 322, 'EYE SEE MOVIES': 323, 'EYE SEE': 323, 'EYK MEDIA': 324, 'EYK': 324, 'FABULOUS FILMS': 325, 'FABULOUS': 325, 'FACTORIS FILMS': 326, 'FACTORIS': 326, 'FARAO RECORDS': 327, 'FARBFILM HOME ENTERTAINMENT': 328, 'FARBFILM ENTERTAINMENT': 328, 'FARBFILM HOME': 328, 'FARBFILM': 328, 'FEELGOOD ENTERTAINMENT': 329, 'FEELGOOD': 329, 'FERNSEHJUWELEN': 330, 'FILM CHEST': 331, 'FILM MEDIA': 332, 'FILM MOVEMENT': 333, 'FILM4': 334, 'FILMART': 335, 'FILMAURO': 336, 'FILMAX': 337, 'FILMCONFECT HOME ENTERTAINMENT': 338, 'FILMCONFECT ENTERTAINMENT': 338, 'FILMCONFECT HOME': 338, 'FILMCONFECT': 338, 'FILMEDIA': 339, 'FILMJUWELEN': 340, 'FILMOTEKA NARODAWA': 341, 'FILMRISE': 342, 'FINAL CUT ENTERTAINMENT': 343, 'FINAL CUT': 343, 'FIREHOUSE 12 RECORDS': 344, 'FIREHOUSE 12': 344, 'FIRST INTERNATIONAL PRODUCTION': 345, 'FIRST INTERNATIONAL': 345, 'FIRST LOOK STUDIOS': 346, 'FIRST LOOK': 346, 'FLAGMAN TRADE': 347, 'FLASHSTAR FILMES': 348, 'FLASHSTAR': 348, 'FLICKER ALLEY': 349, 'FNC ADD CULTURE': 350, 'FOCUS FILMES': 351, 'FOCUS': 351, 'FOKUS MEDIA': 352, 'FOKUSA': 352, 'FOX PATHE EUROPA': 353, 'FOX PATHE': 353, 'FOX EUROPA': 353, 'FOX/MGM': 354, 'FOX MGM': 354, 'MGM': 354, 'MGM/FOX': 354, 'FOX': 354, 'FPE': 355, 'FRANCE TÉLÉVISIONS DISTRIBUTION': 356, 'FRANCE TELEVISIONS DISTRIBUTION': 356, 'FRANCE TELEVISIONS': 356, 'FRANCE': 356, 'FREE DOLPHIN ENTERTAINMENT': 357, 'FREE DOLPHIN': 357, 'FREESTYLE DIGITAL MEDIA': 358, 'FREESTYLE DIGITAL': 358, 'FREESTYLE': 358, 'FREMANTLE HOME ENTERTAINMENT': 359, 'FREMANTLE ENTERTAINMENT': 359, 'FREMANTLE HOME': 359, 'FREMANTL': 359, 'FRENETIC FILMS': 360, 'FRENETIC': 360, 'FRONTIER WORKS': 361, 'FRONTIER': 361, 'FRONTIERS MUSIC': 362, 'FRONTIERS RECORDS': 363, 'FS FILM OY': 364, 'FS FILM': + 364, 'FULL MOON FEATURES': 365, 'FULL MOON': 365, 'FUN CITY EDITIONS': 366, 'FUN CITY': 366, 'FUNIMATION ENTERTAINMENT': 367, 'FUNIMATION': 367, 'FUSION': 368, 'FUTUREFILM': 369, 'G2 PICTURES': 370, 'G2': 370, 'GAGA COMMUNICATIONS': 371, 'GAGA': 371, 'GAIAM': 372, 'GALAPAGOS': 373, 'GAMMA HOME ENTERTAINMENT': 374, 'GAMMA ENTERTAINMENT': 374, 'GAMMA HOME': 374, 'GAMMA': 374, 'GARAGEHOUSE PICTURES': 375, 'GARAGEHOUSE': 375, 'GARAGEPLAY (車庫娛樂)': 376, '車庫娛樂': 376, 'GARAGEPLAY (Che Ku Yu Le )': 376, 'GARAGEPLAY': 376, 'Che Ku Yu Le': 376, 'GAUMONT': 377, 'GEFFEN': 378, 'GENEON ENTERTAINMENT': 379, 'GENEON': 379, 'GENEON UNIVERSAL ENTERTAINMENT': 380, 'GENERAL VIDEO RECORDING': 381, 'GLASS DOLL FILMS': 382, 'GLASS DOLL': 382, 'GLOBE MUSIC MEDIA': 383, 'GLOBE MUSIC': 383, 'GLOBE MEDIA': 383, 'GLOBE': 383, 'GO ENTERTAIN': 384, 'GO': 384, 'GOLDEN HARVEST': 385, 'GOOD!MOVIES': 386, + 'GOOD! MOVIES': 386, 'GOOD MOVIES': 386, 'GRAPEVINE VIDEO': 387, 'GRAPEVINE': 387, 'GRASSHOPPER FILM': 388, 'GRASSHOPPER FILMS': 388, 'GRASSHOPPER': 388, 'GRAVITAS VENTURES': 389, 'GRAVITAS': 389, 'GREAT MOVIES': 390, 'GREAT': 390, + 'GREEN APPLE ENTERTAINMENT': 391, 'GREEN ENTERTAINMENT': 391, 'GREEN APPLE': 391, 'GREEN': 391, 'GREENNARAE MEDIA': 392, 'GREENNARAE': 392, 'GRINDHOUSE RELEASING': 393, 'GRINDHOUSE': 393, 'GRIND HOUSE': 393, 'GRYPHON ENTERTAINMENT': 394, 'GRYPHON': 394, 'GUNPOWDER & SKY': 395, 'GUNPOWDER AND SKY': 395, 'GUNPOWDER SKY': 395, 'GUNPOWDER + SKY': 395, 'GUNPOWDER': 395, 'HANABEE ENTERTAINMENT': 396, 'HANABEE': 396, 'HANNOVER HOUSE': 397, 'HANNOVER': 397, 'HANSESOUND': 398, 'HANSE SOUND': 398, 'HANSE': 398, 'HAPPINET': 399, 'HARMONIA MUNDI': 400, 'HARMONIA': 400, 'HBO': 401, 'HDC': 402, 'HEC': 403, 'HELL & BACK RECORDINGS': 404, 'HELL AND BACK RECORDINGS': 404, 'HELL & BACK': 404, 'HELL AND BACK': 404, "HEN'S TOOTH VIDEO": 405, 'HENS TOOTH VIDEO': 405, "HEN'S TOOTH": 405, 'HENS TOOTH': 405, 'HIGH FLIERS': 406, 'HIGHLIGHT': 407, 'HILLSONG': 408, 'HISTORY CHANNEL': 409, 'HISTORY': 409, 'HK VIDÉO': 410, 'HK VIDEO': 410, 'HK': 410, 'HMH HAMBURGER MEDIEN HAUS': 411, 'HAMBURGER MEDIEN HAUS': 411, 'HMH HAMBURGER MEDIEN': 411, 'HMH HAMBURGER': 411, 'HMH': 411, 'HOLLYWOOD CLASSIC ENTERTAINMENT': 412, 'HOLLYWOOD CLASSIC': 412, 'HOLLYWOOD PICTURES': 413, 'HOLLYWOOD': 413, 'HOPSCOTCH ENTERTAINMENT': 414, 'HOPSCOTCH': 414, 'HPM': 415, 'HÄNNSLER CLASSIC': 416, 'HANNSLER CLASSIC': 416, 'HANNSLER': 416, 'I-CATCHER': 417, 'I CATCHER': 417, 'ICATCHER': 417, 'I-ON NEW MEDIA': 418, 'I ON NEW MEDIA': 418, 'ION NEW MEDIA': 418, 'ION MEDIA': 418, 'I-ON': 418, 'ION': 418, 'IAN PRODUCTIONS': 419, 'IAN': 419, 'ICESTORM': 420, 'ICON FILM DISTRIBUTION': 421, 'ICON DISTRIBUTION': 421, 'ICON FILM': 421, 'ICON': 421, 'IDEALE AUDIENCE': 422, 'IDEALE': 422, 'IFC FILMS': 423, 'IFC': 423, 'IFILM': 424, 'ILLUSIONS UNLTD.': 425, 'ILLUSIONS UNLTD': 425, 'ILLUSIONS': 425, 'IMAGE ENTERTAINMENT': 426, 'IMAGE': 426, + 'IMAGEM FILMES': 427, 'IMAGEM': 427, 'IMOVISION': 428, 'IMPERIAL CINEPIX': 429, 'IMPRINT': 430, 'IMPULS HOME ENTERTAINMENT': 431, 'IMPULS ENTERTAINMENT': 431, 'IMPULS HOME': 431, 'IMPULS': 431, 'IN-AKUSTIK': 432, 'IN AKUSTIK': 432, 'INAKUSTIK': 432, 'INCEPTION MEDIA GROUP': 433, 'INCEPTION MEDIA': 433, 'INCEPTION GROUP': 433, 'INCEPTION': 433, 'INDEPENDENT': 434, 'INDICAN': 435, 'INDIE RIGHTS': 436, 'INDIE': 436, 'INDIGO': 437, 'INFO': 438, 'INJOINGAN': 439, 'INKED PICTURES': 440, 'INKED': 440, 'INSIDE OUT MUSIC': 441, 'INSIDE MUSIC': 441, 'INSIDE OUT': 441, 'INSIDE': 441, 'INTERCOM': 442, 'INTERCONTINENTAL VIDEO': 443, 'INTERCONTINENTAL': 443, 'INTERGROOVE': 444, + 'INTERSCOPE': 445, 'INVINCIBLE PICTURES': 446, 'INVINCIBLE': 446, 'ISLAND/MERCURY': 447, 'ISLAND MERCURY': 447, 'ISLANDMERCURY': 447, 'ISLAND & MERCURY': 447, 'ISLAND AND MERCURY': 447, 'ISLAND': 447, 'ITN': 448, 'ITV DVD': 449, 'ITV': 449, 'IVC': 450, 'IVE ENTERTAINMENT': 451, 'IVE': 451, 'J&R ADVENTURES': 452, 'J&R': 452, 'JR': 452, 'JAKOB': 453, 'JONU MEDIA': 454, 'JONU': 454, 'JRB PRODUCTIONS': 455, 'JRB': 455, 'JUST BRIDGE ENTERTAINMENT': 456, 'JUST BRIDGE': 456, 'JUST ENTERTAINMENT': 456, 'JUST': 456, 'KABOOM ENTERTAINMENT': 457, 'KABOOM': 457, 'KADOKAWA ENTERTAINMENT': 458, 'KADOKAWA': 458, 'KAIROS': 459, 'KALEIDOSCOPE ENTERTAINMENT': 460, 'KALEIDOSCOPE': 460, 'KAM & RONSON ENTERPRISES': 461, 'KAM & RONSON': 461, 'KAM&RONSON ENTERPRISES': 461, 'KAM&RONSON': 461, 'KAM AND RONSON ENTERPRISES': 461, 'KAM AND RONSON': 461, 'KANA HOME VIDEO': 462, 'KARMA FILMS': 463, 'KARMA': 463, 'KATZENBERGER': 464, 'KAZE': 465, 'KBS MEDIA': 466, 'KBS': 466, 'KD MEDIA': 467, 'KD': 467, 'KING MEDIA': 468, 'KING': 468, 'KING RECORDS': 469, 'KINO LORBER': 470, 'KINO': 470, 'KINO SWIAT': 471, 'KINOKUNIYA': 472, 'KINOWELT HOME ENTERTAINMENT/DVD': 473, 'KINOWELT HOME ENTERTAINMENT': 473, 'KINOWELT ENTERTAINMENT': 473, 'KINOWELT HOME DVD': 473, 'KINOWELT ENTERTAINMENT/DVD': 473, 'KINOWELT DVD': 473, 'KINOWELT': 473, 'KIT PARKER FILMS': 474, 'KIT PARKER': 474, 'KITTY MEDIA': 475, 'KNM HOME ENTERTAINMENT': 476, 'KNM ENTERTAINMENT': 476, 'KNM HOME': 476, 'KNM': 476, 'KOBA FILMS': 477, 'KOBA': 477, 'KOCH ENTERTAINMENT': 478, 'KOCH MEDIA': 479, 'KOCH': 479, 'KRAKEN RELEASING': 480, 'KRAKEN': 480, 'KSCOPE': 481, 'KSM': 482, 'KULTUR': 483, "L'ATELIER D'IMAGES": 484, "LATELIER D'IMAGES": 484, "L'ATELIER DIMAGES": 484, 'LATELIER DIMAGES': 484, "L ATELIER D'IMAGES": 484, "L'ATELIER D IMAGES": 484, + 'L ATELIER D IMAGES': 484, "L'ATELIER": 484, 'L ATELIER': 484, 'LATELIER': 484, 'LA AVENTURA AUDIOVISUAL': 485, 'LA AVENTURA': 485, 'LACE GROUP': 486, 'LACE': 486, 'LASER PARADISE': 487, 'LAYONS': 488, 'LCJ EDITIONS': 489, 'LCJ': 489, 'LE CHAT QUI FUME': 490, 'LE PACTE': 491, 'LEDICK FILMHANDEL': 492, 'LEGEND': 493, 'LEOMARK STUDIOS': 494, 'LEOMARK': 494, 'LEONINE FILMS': 495, 'LEONINE': 495, 'LICHTUNG MEDIA LTD': 496, 'LICHTUNG LTD': 496, 'LICHTUNG MEDIA LTD.': 496, 'LICHTUNG LTD.': 496, 'LICHTUNG MEDIA': 496, 'LICHTUNG': 496, 'LIGHTHOUSE HOME ENTERTAINMENT': 497, 'LIGHTHOUSE ENTERTAINMENT': 497, 'LIGHTHOUSE HOME': 497, 'LIGHTHOUSE': 497, 'LIGHTYEAR': 498, 'LIONSGATE FILMS': 499, 'LIONSGATE': 499, 'LIZARD CINEMA TRADE': 500, 'LLAMENTOL': 501, 'LOBSTER FILMS': 502, 'LOBSTER': 502, 'LOGON': 503, 'LORBER FILMS': 504, 'LORBER': 504, 'LOS BANDITOS FILMS': 505, 'LOS BANDITOS': 505, 'LOUD & PROUD RECORDS': 506, 'LOUD AND PROUD RECORDS': 506, 'LOUD & PROUD': 506, 'LOUD AND PROUD': 506, 'LSO LIVE': 507, 'LUCASFILM': 508, 'LUCKY RED': 509, 'LUMIÈRE HOME ENTERTAINMENT': 510, 'LUMIERE HOME ENTERTAINMENT': 510, 'LUMIERE ENTERTAINMENT': 510, 'LUMIERE HOME': 510, 'LUMIERE': 510, 'M6 VIDEO': 511, 'M6': 511, 'MAD DIMENSION': 512, 'MADMAN ENTERTAINMENT': 513, 'MADMAN': 513, 'MAGIC BOX': 514, 'MAGIC PLAY': 515, 'MAGNA HOME ENTERTAINMENT': 516, 'MAGNA ENTERTAINMENT': 516, 'MAGNA HOME': 516, 'MAGNA': 516, 'MAGNOLIA PICTURES': 517, 'MAGNOLIA': 517, 'MAIDEN JAPAN': 518, 'MAIDEN': 518, 'MAJENG MEDIA': 519, 'MAJENG': 519, 'MAJESTIC HOME ENTERTAINMENT': 520, 'MAJESTIC ENTERTAINMENT': 520, 'MAJESTIC HOME': 520, 'MAJESTIC': 520, 'MANGA HOME ENTERTAINMENT': 521, 'MANGA ENTERTAINMENT': 521, 'MANGA HOME': 521, 'MANGA': 521, 'MANTA LAB': 522, 'MAPLE STUDIOS': 523, 'MAPLE': 523, 'MARCO POLO PRODUCTION': + 524, 'MARCO POLO': 524, 'MARIINSKY': 525, 'MARVEL STUDIOS': 526, 'MARVEL': 526, 'MASCOT RECORDS': 527, 'MASCOT': 527, 'MASSACRE VIDEO': 528, 'MASSACRE': 528, 'MATCHBOX': 529, 'MATRIX D': 530, 'MAXAM': 531, 'MAYA HOME ENTERTAINMENT': 532, 'MAYA ENTERTAINMENT': 532, 'MAYA HOME': 532, 'MAYAT': 532, 'MDG': 533, 'MEDIA BLASTERS': 534, 'MEDIA FACTORY': 535, 'MEDIA TARGET DISTRIBUTION': 536, 'MEDIA TARGET': 536, 'MEDIAINVISION': 537, 'MEDIATOON': 538, 'MEDIATRES ESTUDIO': 539, 'MEDIATRES STUDIO': 539, 'MEDIATRES': 539, 'MEDICI ARTS': 540, 'MEDICI CLASSICS': 541, 'MEDIUMRARE ENTERTAINMENT': 542, 'MEDIUMRARE': 542, 'MEDUSA': 543, 'MEGASTAR': 544, 'MEI AH': 545, 'MELI MÉDIAS': 546, 'MELI MEDIAS': 546, 'MEMENTO FILMS': 547, 'MEMENTO': 547, 'MENEMSHA FILMS': 548, 'MENEMSHA': 548, 'MERCURY': 549, 'MERCURY STUDIOS': 550, 'MERGE SOFT PRODUCTIONS': 551, 'MERGE PRODUCTIONS': 551, 'MERGE SOFT': 551, 'MERGE': 551, 'METAL BLADE RECORDS': 552, 'METAL BLADE': 552, 'METEOR': 553, 'METRO-GOLDWYN-MAYER': 554, 'METRO GOLDWYN MAYER': 554, 'METROGOLDWYNMAYER': 554, 'METRODOME VIDEO': 555, 'METRODOME': 555, 'METROPOLITAN': 556, 'MFA+': + 557, 'MFA': 557, 'MIG FILMGROUP': 558, 'MIG': 558, 'MILESTONE': 559, 'MILL CREEK ENTERTAINMENT': 560, 'MILL CREEK': 560, 'MILLENNIUM MEDIA': 561, 'MILLENNIUM': 561, 'MIRAGE ENTERTAINMENT': 562, 'MIRAGE': 562, 'MIRAMAX': 563, + 'MISTERIYA ZVUKA': 564, 'MK2': 565, 'MODE RECORDS': 566, 'MODE': 566, 'MOMENTUM PICTURES': 567, 'MONDO HOME ENTERTAINMENT': 568, 'MONDO ENTERTAINMENT': 568, 'MONDO HOME': 568, 'MONDO MACABRO': 569, 'MONGREL MEDIA': 570, 'MONOLIT': 571, 'MONOLITH VIDEO': 572, 'MONOLITH': 572, 'MONSTER PICTURES': 573, 'MONSTER': 573, 'MONTEREY VIDEO': 574, 'MONTEREY': 574, 'MONUMENT RELEASING': 575, 'MONUMENT': 575, 'MORNINGSTAR': 576, 'MORNING STAR': 576, 'MOSERBAER': 577, 'MOVIEMAX': 578, 'MOVINSIDE': 579, 'MPI MEDIA GROUP': 580, 'MPI MEDIA': 580, 'MPI': 580, 'MR. BONGO FILMS': 581, 'MR BONGO FILMS': 581, 'MR BONGO': 581, 'MRG (MERIDIAN)': 582, 'MRG MERIDIAN': 582, 'MRG': 582, 'MERIDIAN': 582, 'MUBI': 583, 'MUG SHOT PRODUCTIONS': 584, 'MUG SHOT': 584, 'MULTIMUSIC': 585, 'MULTI-MUSIC': 585, 'MULTI MUSIC': 585, 'MUSE': 586, 'MUSIC BOX FILMS': 587, 'MUSIC BOX': 587, 'MUSICBOX': 587, 'MUSIC BROKERS': 588, 'MUSIC THEORIES': 589, 'MUSIC VIDEO DISTRIBUTORS': 590, 'MUSIC VIDEO': 590, 'MUSTANG ENTERTAINMENT': 591, 'MUSTANG': 591, 'MVD VISUAL': 592, 'MVD': 592, 'MVD/VSC': 593, 'MVL': 594, 'MVM ENTERTAINMENT': 595, 'MVM': 595, 'MYNDFORM': 596, 'MYSTIC NIGHT PICTURES': 597, 'MYSTIC NIGHT': 597, 'NAMELESS MEDIA': 598, 'NAMELESS': 598, 'NAPALM RECORDS': 599, 'NAPALM': 599, 'NATIONAL ENTERTAINMENT MEDIA': 600, 'NATIONAL ENTERTAINMENT': 600, 'NATIONAL MEDIA': 600, 'NATIONAL FILM ARCHIVE': 601, 'NATIONAL ARCHIVE': 601, 'NATIONAL FILM': 601, 'NATIONAL GEOGRAPHIC': 602, 'NAT GEO TV': 602, 'NAT GEO': 602, 'NGO': 602, 'NAXOS': 603, 'NBCUNIVERSAL ENTERTAINMENT JAPAN': 604, 'NBC UNIVERSAL ENTERTAINMENT JAPAN': 604, 'NBCUNIVERSAL JAPAN': 604, 'NBC UNIVERSAL JAPAN': 604, 'NBC JAPAN': 604, 'NBO ENTERTAINMENT': 605, 'NBO': 605, 'NEOS': 606, 'NETFLIX': 607, 'NETWORK': 608, 'NEW BLOOD': 609, 'NEW DISC': 610, 'NEW KSM': 611, 'NEW LINE CINEMA': 612, 'NEW LINE': 612, 'NEW MOVIE TRADING CO. LTD': 613, 'NEW MOVIE TRADING CO LTD': 613, 'NEW MOVIE TRADING CO': 613, 'NEW MOVIE TRADING': 613, 'NEW WAVE FILMS': 614, 'NEW WAVE': 614, 'NFI': 615, + 'NHK': 616, 'NIPPONART': 617, 'NIS AMERICA': 618, 'NJUTAFILMS': 619, 'NOBLE ENTERTAINMENT': 620, 'NOBLE': 620, 'NORDISK FILM': 621, 'NORDISK': 621, 'NORSK FILM': 622, 'NORSK': 622, 'NORTH AMERICAN MOTION PICTURES': 623, 'NOS AUDIOVISUAIS': 624, 'NOTORIOUS PICTURES': 625, 'NOTORIOUS': 625, 'NOVA MEDIA': 626, 'NOVA': 626, 'NOVA SALES AND DISTRIBUTION': 627, 'NOVA SALES & DISTRIBUTION': 627, 'NSM': 628, 'NSM RECORDS': 629, 'NUCLEAR BLAST': 630, 'NUCLEUS FILMS': 631, 'NUCLEUS': 631, 'OBERLIN MUSIC': 632, 'OBERLIN': 632, 'OBRAS-PRIMAS DO CINEMA': 633, 'OBRAS PRIMAS DO CINEMA': 633, 'OBRASPRIMAS DO CINEMA': 633, 'OBRAS-PRIMAS CINEMA': 633, 'OBRAS PRIMAS CINEMA': 633, 'OBRASPRIMAS CINEMA': 633, 'OBRAS-PRIMAS': 633, 'OBRAS PRIMAS': 633, 'OBRASPRIMAS': 633, 'ODEON': 634, 'OFDB FILMWORKS': 635, 'OFDB': 635, 'OLIVE FILMS': 636, 'OLIVE': 636, 'ONDINE': 637, 'ONSCREEN FILMS': 638, 'ONSCREEN': 638, 'OPENING DISTRIBUTION': 639, 'OPERA AUSTRALIA': 640, 'OPTIMUM HOME ENTERTAINMENT': 641, 'OPTIMUM ENTERTAINMENT': 641, 'OPTIMUM HOME': 641, 'OPTIMUM': 641, 'OPUS ARTE': 642, 'ORANGE STUDIO': 643, 'ORANGE': 643, 'ORLANDO EASTWOOD FILMS': 644, 'ORLANDO FILMS': 644, 'ORLANDO EASTWOOD': 644, 'ORLANDO': 644, 'ORUSTAK PICTURES': 645, 'ORUSTAK': 645, 'OSCILLOSCOPE PICTURES': 646, 'OSCILLOSCOPE': 646, 'OUTPLAY': 647, 'PALISADES TARTAN': 648, 'PAN VISION': 649, 'PANVISION': 649, 'PANAMINT CINEMA': 650, 'PANAMINT': 650, 'PANDASTORM ENTERTAINMENT': 651, 'PANDA STORM ENTERTAINMENT': 651, 'PANDASTORM': 651, 'PANDA STORM': 651, 'PANDORA FILM': 652, 'PANDORA': 652, 'PANEGYRIC': 653, 'PANORAMA': 654, 'PARADE DECK FILMS': 655, 'PARADE DECK': 655, 'PARADISE': 656, 'PARADISO FILMS': 657, 'PARADOX': 658, 'PARAMOUNT PICTURES': 659, 'PARAMOUNT': 659, 'PARIS FILMES': 660, 'PARIS FILMS': 660, 'PARIS': 660, 'PARK CIRCUS': 661, 'PARLOPHONE': 662, 'PASSION RIVER': 663, 'PATHE DISTRIBUTION': 664, 'PATHE': 664, 'PBS': 665, 'PEACE ARCH TRINITY': 666, 'PECCADILLO PICTURES': 667, 'PEPPERMINT': 668, 'PHASE 4 FILMS': 669, 'PHASE 4': 669, 'PHILHARMONIA BAROQUE': 670, 'PICTURE HOUSE ENTERTAINMENT': 671, 'PICTURE ENTERTAINMENT': 671, 'PICTURE HOUSE': 671, 'PICTURE': 671, 'PIDAX': 672, 'PINK FLOYD RECORDS': 673, 'PINK FLOYD': 673, 'PINNACLE FILMS': 674, 'PINNACLE': 674, 'PLAIN': 675, 'PLATFORM ENTERTAINMENT LIMITED': 676, 'PLATFORM ENTERTAINMENT LTD': 676, 'PLATFORM ENTERTAINMENT LTD.': 676, 'PLATFORM ENTERTAINMENT': 676, 'PLATFORM': 676, 'PLAYARTE': 677, 'PLG UK CLASSICS': 678, 'PLG UK': + 678, 'PLG': 678, 'POLYBAND & TOPPIC VIDEO/WVG': 679, 'POLYBAND AND TOPPIC VIDEO/WVG': 679, 'POLYBAND & TOPPIC VIDEO WVG': 679, 'POLYBAND & TOPPIC VIDEO AND WVG': 679, 'POLYBAND & TOPPIC VIDEO & WVG': 679, 'POLYBAND AND TOPPIC VIDEO WVG': 679, 'POLYBAND AND TOPPIC VIDEO AND WVG': 679, 'POLYBAND AND TOPPIC VIDEO & WVG': 679, 'POLYBAND & TOPPIC VIDEO': 679, 'POLYBAND AND TOPPIC VIDEO': 679, 'POLYBAND & TOPPIC': 679, 'POLYBAND AND TOPPIC': 679, 'POLYBAND': 679, 'WVG': 679, 'POLYDOR': 680, 'PONY': 681, 'PONY CANYON': 682, 'POTEMKINE': 683, 'POWERHOUSE FILMS': 684, 'POWERHOUSE': 684, 'POWERSTATIOM': 685, 'PRIDE & JOY': 686, 'PRIDE AND JOY': 686, 'PRINZ MEDIA': 687, 'PRINZ': 687, 'PRIS AUDIOVISUAIS': 688, 'PRO VIDEO': 689, 'PRO-VIDEO': 689, 'PRO-MOTION': 690, 'PRO MOTION': 690, 'PROD. JRB': 691, 'PROD JRB': 691, 'PRODISC': 692, 'PROKINO': 693, 'PROVOGUE RECORDS': 694, 'PROVOGUE': 694, 'PROWARE': 695, 'PULP VIDEO': 696, 'PULP': 696, 'PULSE VIDEO': 697, 'PULSE': 697, 'PURE AUDIO RECORDINGS': 698, 'PURE AUDIO': 698, 'PURE FLIX ENTERTAINMENT': 699, 'PURE FLIX': 699, 'PURE ENTERTAINMENT': 699, 'PYRAMIDE VIDEO': 700, 'PYRAMIDE': 700, 'QUALITY FILMS': 701, 'QUALITY': 701, 'QUARTO VALLEY RECORDS': 702, 'QUARTO VALLEY': 702, 'QUESTAR': 703, 'R SQUARED FILMS': 704, 'R SQUARED': 704, 'RAPID EYE MOVIES': 705, 'RAPID EYE': 705, 'RARO VIDEO': 706, 'RARO': 706, 'RAROVIDEO U.S.': 707, 'RAROVIDEO US': 707, 'RARO VIDEO US': 707, 'RARO VIDEO U.S.': 707, 'RARO U.S.': 707, 'RARO US': 707, 'RAVEN BANNER RELEASING': 708, 'RAVEN BANNER': 708, 'RAVEN': 708, 'RAZOR DIGITAL ENTERTAINMENT': 709, 'RAZOR DIGITAL': 709, 'RCA': 710, 'RCO LIVE': 711, 'RCO': 711, 'RCV': 712, 'REAL GONE MUSIC': 713, 'REAL GONE': 713, 'REANIMEDIA': 714, 'REANI MEDIA': 714, 'REDEMPTION': 715, 'REEL': 716, 'RELIANCE HOME VIDEO & GAMES': 717, 'RELIANCE HOME VIDEO AND GAMES': 717, 'RELIANCE HOME VIDEO': 717, 'RELIANCE VIDEO': 717, 'RELIANCE HOME': 717, 'RELIANCE': 717, 'REM CULTURE': 718, 'REMAIN IN LIGHT': 719, 'REPRISE': 720, 'RESEN': 721, 'RETROMEDIA': 722, 'REVELATION FILMS LTD.': 723, 'REVELATION FILMS LTD': 723, 'REVELATION FILMS': 723, 'REVELATION LTD.': 723, 'REVELATION LTD': 723, 'REVELATION': 723, 'REVOLVER ENTERTAINMENT': 724, 'REVOLVER': 724, 'RHINO MUSIC': 725, 'RHINO': 725, 'RHV': 726, 'RIGHT STUF': 727, 'RIMINI EDITIONS': 728, 'RISING SUN MEDIA': 729, 'RLJ ENTERTAINMENT': 730, 'RLJ': 730, 'ROADRUNNER RECORDS': 731, 'ROADSHOW ENTERTAINMENT': 732, 'ROADSHOW': 732, 'RONE': 733, 'RONIN FLIX': 734, 'ROTANA HOME ENTERTAINMENT': 735, 'ROTANA ENTERTAINMENT': 735, 'ROTANA HOME': 735, 'ROTANA': 735, 'ROUGH TRADE': 736, 'ROUNDER': 737, 'SAFFRON HILL FILMS': 738, 'SAFFRON HILL': 738, 'SAFFRON': 738, 'SAMUEL GOLDWYN FILMS': 739, 'SAMUEL GOLDWYN': 739, 'SAN FRANCISCO SYMPHONY': 740, 'SANDREW METRONOME': 741, 'SAPHRANE': 742, 'SAVOR': 743, 'SCANBOX ENTERTAINMENT': 744, 'SCANBOX': 744, 'SCENIC LABS': 745, 'SCHRÖDERMEDIA': 746, 'SCHRODERMEDIA': 746, 'SCHRODER MEDIA': 746, 'SCORPION RELEASING': 747, 'SCORPION': 747, 'SCREAM TEAM RELEASING': 748, 'SCREAM TEAM': 748, 'SCREEN MEDIA': 749, 'SCREEN': 749, 'SCREENBOUND PICTURES': 750, 'SCREENBOUND': 750, 'SCREENWAVE MEDIA': 751, 'SCREENWAVE': 751, 'SECOND RUN': 752, 'SECOND SIGHT': 753, 'SEEDSMAN GROUP': 754, 'SELECT VIDEO': 755, 'SELECTA VISION': 756, 'SENATOR': 757, 'SENTAI FILMWORKS': 758, 'SENTAI': 758, 'SEVEN7': 759, 'SEVERIN FILMS': 760, 'SEVERIN': 760, 'SEVILLE': 761, 'SEYONS ENTERTAINMENT': 762, 'SEYONS': 762, 'SF STUDIOS': 763, 'SGL ENTERTAINMENT': 764, 'SGL': 764, 'SHAMELESS': 765, 'SHAMROCK MEDIA': 766, 'SHAMROCK': 766, 'SHANGHAI EPIC MUSIC ENTERTAINMENT': 767, 'SHANGHAI EPIC ENTERTAINMENT': 767, 'SHANGHAI EPIC MUSIC': 767, 'SHANGHAI MUSIC ENTERTAINMENT': 767, 'SHANGHAI ENTERTAINMENT': 767, 'SHANGHAI MUSIC': 767, 'SHANGHAI': 767, 'SHEMAROO': 768, 'SHOCHIKU': 769, 'SHOCK': 770, 'SHOGAKU KAN': 771, 'SHOUT FACTORY': 772, 'SHOUT! FACTORY': 772, 'SHOUT': 772, 'SHOUT!': 772, 'SHOWBOX': 773, 'SHOWTIME ENTERTAINMENT': 774, 'SHOWTIME': 774, 'SHRIEK SHOW': 775, 'SHUDDER': 776, 'SIDONIS': 777, 'SIDONIS CALYSTA': 778, 'SIGNAL ONE ENTERTAINMENT': 779, 'SIGNAL ONE': 779, 'SIGNATURE ENTERTAINMENT': 780, 'SIGNATURE': 780, 'SILVER VISION': 781, 'SINISTER FILM': 782, 'SINISTER': 782, 'SIREN VISUAL ENTERTAINMENT': 783, 'SIREN VISUAL': 783, 'SIREN ENTERTAINMENT': 783, 'SIREN': 783, 'SKANI': 784, 'SKY DIGI': 785, 'SLASHER // VIDEO': 786, 'SLASHER / VIDEO': 786, 'SLASHER VIDEO': 786, 'SLASHER': 786, 'SLOVAK FILM INSTITUTE': 787, 'SLOVAK FILM': 787, + 'SFI': 787, 'SM LIFE DESIGN GROUP': 788, 'SMOOTH PICTURES': 789, 'SMOOTH': 789, 'SNAPPER MUSIC': 790, 'SNAPPER': 790, 'SODA PICTURES': 791, 'SODA': 791, 'SONO LUMINUS': 792, 'SONY MUSIC': 793, 'SONY PICTURES': 794, 'SONY': 794, 'SONY PICTURES CLASSICS': 795, 'SONY CLASSICS': 795, 'SOUL MEDIA': 796, 'SOUL': 796, 'SOULFOOD MUSIC DISTRIBUTION': 797, 'SOULFOOD DISTRIBUTION': 797, 'SOULFOOD MUSIC': 797, 'SOULFOOD': 797, 'SOYUZ': 798, 'SPECTRUM': 799, + 'SPENTZOS FILM': 800, 'SPENTZOS': 800, 'SPIRIT ENTERTAINMENT': 801, 'SPIRIT': 801, 'SPIRIT MEDIA GMBH': 802, 'SPIRIT MEDIA': 802, 'SPLENDID ENTERTAINMENT': 803, 'SPLENDID FILM': 804, 'SPO': 805, 'SQUARE ENIX': 806, 'SRI BALAJI VIDEO': 807, 'SRI BALAJI': 807, 'SRI': 807, 'SRI VIDEO': 807, 'SRS CINEMA': 808, 'SRS': 808, 'SSO RECORDINGS': 809, 'SSO': 809, 'ST2 MUSIC': 810, 'ST2': 810, 'STAR MEDIA ENTERTAINMENT': 811, 'STAR ENTERTAINMENT': 811, 'STAR MEDIA': 811, 'STAR': 811, 'STARLIGHT': 812, 'STARZ / ANCHOR BAY': 813, 'STARZ ANCHOR BAY': 813, 'STARZ': 813, 'ANCHOR BAY': 813, 'STER KINEKOR': 814, 'STERLING ENTERTAINMENT': 815, 'STERLING': 815, 'STINGRAY': 816, 'STOCKFISCH RECORDS': 817, 'STOCKFISCH': 817, 'STRAND RELEASING': 818, 'STRAND': 818, 'STUDIO 4K': 819, 'STUDIO CANAL': 820, 'STUDIO GHIBLI': 821, 'GHIBLI': 821, 'STUDIO HAMBURG ENTERPRISES': 822, 'HAMBURG ENTERPRISES': 822, 'STUDIO HAMBURG': 822, 'HAMBURG': 822, 'STUDIO S': 823, 'SUBKULTUR ENTERTAINMENT': 824, 'SUBKULTUR': 824, 'SUEVIA FILMS': 825, 'SUEVIA': 825, 'SUMMIT ENTERTAINMENT': 826, 'SUMMIT': 826, 'SUNFILM ENTERTAINMENT': 827, 'SUNFILM': 827, 'SURROUND RECORDS': 828, 'SURROUND': 828, 'SVENSK FILMINDUSTRI': 829, 'SVENSK': 829, 'SWEN FILMES': 830, 'SWEN FILMS': 830, 'SWEN': 830, 'SYNAPSE FILMS': 831, 'SYNAPSE': 831, 'SYNDICADO': 832, 'SYNERGETIC': 833, 'T- SERIES': 834, 'T-SERIES': 834, 'T SERIES': 834, 'TSERIES': 834, 'T.V.P.': 835, 'TVP': 835, 'TACET RECORDS': 836, 'TACET': 836, 'TAI SENG': 837, 'TAI SHENG': 838, 'TAKEONE': 839, 'TAKESHOBO': 840, 'TAMASA DIFFUSION': 841, 'TC ENTERTAINMENT': 842, 'TC': 842, 'TDK': 843, 'TEAM MARKETING': 844, 'TEATRO REAL': 845, 'TEMA DISTRIBUCIONES': 846, 'TEMPE DIGITAL': 847, 'TF1 VIDÉO': 848, 'TF1 VIDEO': 848, 'TF1': 848, 'THE BLU': 849, 'BLU': 849, 'THE ECSTASY OF FILMS': 850, 'THE FILM DETECTIVE': 851, 'FILM DETECTIVE': 851, 'THE JOKERS': 852, 'JOKERS': 852, 'THE ON': 853, 'ON': 853, 'THIMFILM': 854, 'THIM FILM': 854, 'THIM': 854, 'THIRD WINDOW FILMS': 855, 'THIRD WINDOW': 855, '3RD WINDOW FILMS': 855, '3RD WINDOW': 855, 'THUNDERBEAN ANIMATION': 856, 'THUNDERBEAN': 856, 'THUNDERBIRD RELEASING': 857, 'THUNDERBIRD': 857, 'TIBERIUS FILM': 858, 'TIME LIFE': 859, 'TIMELESS MEDIA GROUP': 860, 'TIMELESS MEDIA': 860, 'TIMELESS GROUP': 860, 'TIMELESS': 860, 'TLA RELEASING': 861, 'TLA': 861, 'TOBIS FILM': 862, 'TOBIS': 862, 'TOEI': 863, 'TOHO': 864, 'TOKYO SHOCK': 865, 'TOKYO': 865, 'TONPOOL MEDIEN GMBH': 866, 'TONPOOL MEDIEN': 866, 'TOPICS ENTERTAINMENT': 867, 'TOPICS': 867, 'TOUCHSTONE PICTURES': 868, 'TOUCHSTONE': 868, 'TRANSMISSION FILMS': 869, 'TRANSMISSION': 869, 'TRAVEL VIDEO STORE': 870, 'TRIART': 871, 'TRIGON FILM': 872, 'TRIGON': 872, 'TRINITY HOME ENTERTAINMENT': 873, 'TRINITY ENTERTAINMENT': 873, 'TRINITY HOME': 873, 'TRINITY': 873, 'TRIPICTURES': 874, 'TRI-PICTURES': 874, 'TRI PICTURES': 874, 'TROMA': 875, 'TURBINE MEDIEN': 876, 'TURTLE RECORDS': 877, 'TURTLE': 877, 'TVA FILMS': 878, 'TVA': 878, 'TWILIGHT TIME': 879, 'TWILIGHT': 879, 'TT': 879, 'TWIN CO., LTD.': 880, 'TWIN CO, LTD.': 880, 'TWIN CO., LTD': 880, 'TWIN CO, LTD': 880, 'TWIN CO LTD': 880, 'TWIN LTD': 880, 'TWIN CO.': 880, 'TWIN CO': 880, 'TWIN': 880, 'UCA': 881, 'UDR': 882, 'UEK': 883, 'UFA/DVD': 884, 'UFA DVD': 884, 'UFADVD': 884, 'UGC PH': 885, 'ULTIMATE3DHEAVEN': 886, 'ULTRA': 887, 'UMBRELLA ENTERTAINMENT': 888, 'UMBRELLA': 888, 'UMC': 889, "UNCORK'D ENTERTAINMENT": 890, 'UNCORKD ENTERTAINMENT': 890, 'UNCORK D ENTERTAINMENT': 890, "UNCORK'D": 890, 'UNCORK D': 890, 'UNCORKD': 890, 'UNEARTHED FILMS': 891, 'UNEARTHED': 891, 'UNI DISC': 892, 'UNIMUNDOS': 893, 'UNITEL': 894, 'UNIVERSAL MUSIC': 895, 'UNIVERSAL SONY PICTURES HOME ENTERTAINMENT': 896, 'UNIVERSAL SONY PICTURES ENTERTAINMENT': 896, 'UNIVERSAL SONY PICTURES HOME': 896, 'UNIVERSAL SONY PICTURES': 896, 'UNIVERSAL HOME ENTERTAINMENT': + 896, 'UNIVERSAL ENTERTAINMENT': 896, 'UNIVERSAL HOME': 896, 'UNIVERSAL STUDIOS': 897, 'UNIVERSAL': 897, 'UNIVERSE LASER & VIDEO CO.': 898, 'UNIVERSE LASER AND VIDEO CO.': 898, 'UNIVERSE LASER & VIDEO CO': 898, 'UNIVERSE LASER AND VIDEO CO': 898, 'UNIVERSE LASER CO.': 898, 'UNIVERSE LASER CO': 898, 'UNIVERSE LASER': 898, 'UNIVERSUM FILM': 899, 'UNIVERSUM': 899, 'UTV': 900, 'VAP': 901, 'VCI': 902, 'VENDETTA FILMS': 903, 'VENDETTA': 903, 'VERSÁTIL HOME VIDEO': 904, 'VERSÁTIL VIDEO': 904, 'VERSÁTIL HOME': 904, 'VERSÁTIL': 904, 'VERSATIL HOME VIDEO': 904, 'VERSATIL VIDEO': 904, 'VERSATIL HOME': 904, 'VERSATIL': 904, 'VERTICAL ENTERTAINMENT': 905, 'VERTICAL': 905, 'VÉRTICE 360º': 906, 'VÉRTICE 360': 906, 'VERTICE 360o': 906, 'VERTICE 360': 906, 'VERTIGO BERLIN': 907, 'VÉRTIGO FILMS': 908, 'VÉRTIGO': 908, 'VERTIGO FILMS': 908, 'VERTIGO': 908, 'VERVE PICTURES': 909, 'VIA VISION ENTERTAINMENT': 910, 'VIA VISION': 910, 'VICOL ENTERTAINMENT': 911, 'VICOL': 911, 'VICOM': 912, 'VICTOR ENTERTAINMENT': 913, 'VICTOR': 913, 'VIDEA CDE': 914, 'VIDEO FILM EXPRESS': 915, 'VIDEO FILM': 915, 'VIDEO EXPRESS': 915, 'VIDEO MUSIC, INC.': 916, 'VIDEO MUSIC, INC': 916, 'VIDEO MUSIC INC.': 916, 'VIDEO MUSIC INC': 916, 'VIDEO MUSIC': 916, 'VIDEO SERVICE CORP.': 917, 'VIDEO SERVICE CORP': 917, 'VIDEO SERVICE': 917, 'VIDEO TRAVEL': 918, 'VIDEOMAX': 919, 'VIDEO MAX': 919, 'VII PILLARS ENTERTAINMENT': 920, 'VII PILLARS': 920, 'VILLAGE FILMS': 921, 'VINEGAR SYNDROME': 922, 'VINEGAR': 922, 'VS': 922, 'VINNY MOVIES': 923, 'VINNY': 923, 'VIRGIL FILMS & ENTERTAINMENT': 924, 'VIRGIL FILMS AND ENTERTAINMENT': 924, 'VIRGIL ENTERTAINMENT': 924, 'VIRGIL FILMS': 924, 'VIRGIL': 924, 'VIRGIN RECORDS': 925, 'VIRGIN': 925, 'VISION FILMS': 926, 'VISION': 926, 'VISUAL ENTERTAINMENT GROUP': 927, 'VISUAL GROUP': 927, 'VISUAL ENTERTAINMENT': 927, 'VISUAL': 927, 'VIVENDI VISUAL ENTERTAINMENT': 928, 'VIVENDI VISUAL': 928, 'VIVENDI': 928, 'VIZ PICTURES': 929, 'VIZ': 929, 'VLMEDIA': 930, 'VL MEDIA': 930, 'VL': 930, 'VOLGA': 931, 'VVS FILMS': 932, + 'VVS': 932, 'VZ HANDELS GMBH': 933, 'VZ HANDELS': 933, 'WARD RECORDS': 934, 'WARD': 934, 'WARNER BROS.': 935, 'WARNER BROS': 935, 'WARNER ARCHIVE': 935, 'WARNER ARCHIVE COLLECTION': 935, 'WAC': 935, 'WARNER': 935, 'WARNER MUSIC': 936, 'WEA': 937, 'WEINSTEIN COMPANY': 938, 'WEINSTEIN': 938, 'WELL GO USA': 939, 'WELL GO': 939, 'WELTKINO FILMVERLEIH': 940, 'WEST VIDEO': 941, 'WEST': 941, 'WHITE PEARL MOVIES': 942, 'WHITE PEARL': 942, 'WICKED-VISION MEDIA': 943, 'WICKED VISION MEDIA': 943, 'WICKEDVISION MEDIA': 943, 'WICKED-VISION': 943, 'WICKED VISION': 943, 'WICKEDVISION': 943, 'WIENERWORLD': 944, 'WILD BUNCH': 945, 'WILD EYE RELEASING': 946, 'WILD EYE': 946, 'WILD SIDE VIDEO': 947, 'WILD SIDE': 947, 'WME': 948, 'WOLFE VIDEO': 949, 'WOLFE': 949, 'WORD ON FIRE': 950, 'WORKS FILM GROUP': 951, 'WORLD WRESTLING': 952, 'WVG MEDIEN': 953, 'WWE STUDIOS': 954, 'WWE': 954, 'X RATED KULT': 955, 'X-RATED KULT': 955, 'X RATED CULT': 955, 'X-RATED CULT': 955, 'X RATED': 955, 'X-RATED': 955, 'XCESS': 956, 'XLRATOR': 957, 'XT VIDEO': 958, 'XT': 958, 'YAMATO VIDEO': 959, 'YAMATO': 959, 'YASH RAJ FILMS': 960, 'YASH RAJS': 960, 'ZEITGEIST FILMS': 961, 'ZEITGEIST': 961, 'ZENITH PICTURES': 962, 'ZENITH': 962, 'ZIMA': 963, 'ZYLO': 964, 'ZYX MUSIC': 965, 'ZYX': 965 - }.get(distributor, 0) - return distributor_id + } - async def unit3d_torrent_info(self, tracker, torrent_url, id): - tmdb = imdb = tvdb = description = category = infohash = mal = None - imagelist = [] - params = {'api_token' : self.config['TRACKERS'][tracker].get('api_key', '')} + if reverse: + for name, id_value in distributor_map.items(): + if id_value == distributor_id: + return name + return None + else: + return distributor_map.get(distributor, 0) + + async def prompt_user_for_id_selection(self, meta, tmdb=None, imdb=None, tvdb=None, mal=None, filename=None, tracker_name=None): + if not tracker_name: + tracker_name = "Tracker" # Fallback if tracker_name is not provided + + if imdb: + imdb = str(imdb).zfill(7) # Convert to string and ensure IMDb ID is 7 characters long by adding leading zeros + # console.print(f"[cyan]Found IMDb ID: https://www.imdb.com/title/tt{imdb}[/cyan]") + + if any([tmdb, imdb, tvdb, mal]): + console.print(f"[cyan]Found the following IDs on {tracker_name}:") + if tmdb: + console.print(f"TMDb ID: {tmdb}") + if imdb: + console.print(f"IMDb ID: https://www.imdb.com/title/tt{imdb}") + if tvdb: + console.print(f"TVDb ID: {tvdb}") + if mal: + console.print(f"MAL ID: {mal}") + + if filename: + console.print(f"Filename: {filename}") # Ensure filename is printed if available + + if not meta['unattended']: + selection = input(f"Do you want to use these IDs from {tracker_name}? (Y/n): ").strip().lower() + try: + if selection == '' or selection == 'y' or selection == 'yes': + return True + else: + return False + except (KeyboardInterrupt, EOFError): + sys.exit(1) + else: + return True + + async def prompt_user_for_confirmation(self, message): + response = input(f"{message} (Y/n): ").strip().lower() + if response == '' or response == 'y': + return True + return False + + async def unit3d_region_distributor(self, meta, tracker, torrent_url, id=None): + """Get region and distributor information from API response""" + params = {'api_token': self.config['TRACKERS'][tracker].get('api_key', '')} url = f"{torrent_url}{id}" response = requests.get(url=url, params=params) try: - response = response.json() - attributes = response['attributes'] - category = attributes.get('category') - description = attributes.get('description') - tmdb = attributes.get('tmdb_id') - tvdb = attributes.get('tvdb_id') - mal = attributes.get('mal_id') - imdb = attributes.get('imdb_id') - infohash = attributes.get('info_hash') - - bbcode = BBCODE() - description, imagelist = bbcode.clean_unit3d_description(description, torrent_url) - console.print(f"[green]Successfully grabbed description from {tracker}") - except Exception: - console.print(traceback.print_exc()) - console.print(f"[yellow]Invalid Response from {tracker} API.") - + json_response = response.json() + except ValueError: + return + try: + data = json_response.get('data', []) + if data == "404": + console.print("[yellow]No data found (404). Returning None.[/yellow]") + return + if data and isinstance(data, list): + attributes = data[0].get('attributes', {}) + + region_id = attributes.get('region_id') + distributor_id = attributes.get('distributor_id') + + if meta['debug']: + console.print(f"[blue]Region ID: {region_id}[/blue]") + console.print(f"[blue]Distributor ID: {distributor_id}[/blue]") + + # use reverse to reverse map the id to the name + if not meta.get('region') and region_id: + region_name = await self.unit3d_region_ids(None, reverse=True, region_id=region_id) + if region_name: + meta['region'] = region_name + if meta['debug']: + console.print(f"[green]Mapped region_id {region_id} to '{region_name}'[/green]") + + # use reverse to reverse map the id to the name + if not meta.get('distributor') and distributor_id: + distributor_name = await self.unit3d_distributor_ids(None, reverse=True, distributor_id=distributor_id) + if distributor_name: + meta['distributor'] = distributor_name + if meta['debug']: + console.print(f"[green]Mapped distributor_id {distributor_id} to '{distributor_name}'[/green]") + return + + else: + # Handle direct attributes from JSON response (when not in a list) + attributes = json_response.get('attributes', {}) + if attributes: + region_id = attributes.get('region_id') + distributor_id = attributes.get('distributor_id') + + if meta['debug']: + console.print(f"[blue]Region ID: {region_id}[/blue]") + console.print(f"[blue]Distributor ID: {distributor_id}[/blue]") + + if not meta.get('region') and region_id: + region_name = await self.unit3d_region_ids(None, reverse=True, region_id=region_id) + if region_name: + meta['region'] = region_name + if meta['debug']: + console.print(f"[green]Mapped region_id {region_id} to '{region_name}'[/green]") + + if not meta.get('distributor') and distributor_id: + distributor_name = await self.unit3d_distributor_ids(None, reverse=True, distributor_id=distributor_id) + if distributor_name: + meta['distributor'] = distributor_name + if meta['debug']: + console.print(f"[green]Mapped distributor_id {distributor_id} to '{distributor_name}'[/green]") + except Exception as e: + console.print_exception() + console.print(f"[yellow]Invalid Response from {tracker} API. Error: {str(e)}[/yellow]") + return + + async def unit3d_torrent_info(self, tracker, torrent_url, search_url, meta, id=None, file_name=None, only_id=False): + tmdb = imdb = tvdb = description = category = infohash = mal = files = None # noqa F841 + imagelist = [] + + # Build the params for the API request + params = {'api_token': self.config['TRACKERS'][tracker].get('api_key', '')} + + # Determine the search method and add parameters accordingly + if file_name: + params['file_name'] = file_name # Add file_name to params + if meta.get('debug'): + console.print(f"[green]Searching {tracker} by file name: [bold yellow]{file_name}[/bold yellow]") + url = search_url + elif id: + url = f"{torrent_url}{id}" + if meta.get('debug'): + console.print(f"[green]Searching {tracker} by ID: [bold yellow]{id}[/bold yellow] via {url}") + else: + if meta.get('debug'): + console.print("[red]No ID or file name provided for search.[/red]") + return None, None, None, None, None, None, None, None, None + + # Make the GET request with proper encoding handled by 'params' + response = requests.get(url=url, params=params) + # console.print(f"[blue]Raw API Response: {response}[/blue]") + + try: + json_response = response.json() + + # console.print(f"Raw API Response: {json_response}", markup=False) + + except ValueError: + return None, None, None, None, None, None, None, None, None + + try: + # Handle response when searching by file name (which might return a 'data' array) + data = json_response.get('data', []) + if data == "404": + console.print("[yellow]No data found (404). Returning None.[/yellow]") + return None, None, None, None, None, None, None, None, None + + if data and isinstance(data, list): # Ensure data is a list before accessing it + attributes = data[0].get('attributes', {}) + + # Extract data from the attributes + category = attributes.get('category') + description = attributes.get('description') + tmdb = int(attributes.get('tmdb_id') or 0) + tvdb = int(attributes.get('tvdb_id') or 0) + mal = int(attributes.get('mal_id') or 0) + imdb = int(attributes.get('imdb_id') or 0) + infohash = attributes.get('info_hash') + tmdb = 0 if tmdb == 0 else tmdb + tvdb = 0 if tvdb == 0 else tvdb + mal = 0 if mal == 0 else mal + imdb = 0 if imdb == 0 else imdb + if not meta.get('region') and meta.get('is_disc') == "BDMV": + region_id = attributes.get('region_id') + region_name = await self.unit3d_region_ids(None, reverse=True, region_id=region_id) + if region_name: + meta['region'] = region_name + if not meta.get('distributor') and meta.get('is_disc') == "BDMV": + distributor_id = attributes.get('distributor_id') + distributor_name = await self.unit3d_distributor_ids(None, reverse=True, distributor_id=distributor_id) + if distributor_name: + meta['distributor'] = distributor_name + else: + # Handle response when searching by ID + if id and not data: + attributes = json_response.get('attributes', {}) - return tmdb, imdb, tvdb, mal, description, category, infohash, imagelist + # Extract data from the attributes + category = attributes.get('category') + description = attributes.get('description') + tmdb = int(attributes.get('tmdb_id') or 0) + tvdb = int(attributes.get('tvdb_id') or 0) + mal = int(attributes.get('mal_id') or 0) + imdb = int(attributes.get('imdb_id') or 0) + infohash = attributes.get('info_hash') + tmdb = 0 if tmdb == 0 else tmdb + tvdb = 0 if tvdb == 0 else tvdb + mal = 0 if mal == 0 else mal + imdb = 0 if imdb == 0 else imdb + if not meta.get('region') and meta.get('is_disc') == "BDMV": + region_id = attributes.get('region_id') + region_name = await self.unit3d_region_ids(None, reverse=True, region_id=region_id) + if region_name: + meta['region'] = region_name + if not meta.get('distributor') and meta.get('is_disc') == "BDMV": + distributor_id = attributes.get('distributor_id') + distributor_name = await self.unit3d_distributor_ids(None, reverse=True, distributor_id=distributor_id) + if distributor_name: + meta['distributor'] = distributor_name + # Handle file name extraction + files = attributes.get('files', []) + if files: + if len(files) == 1: + file_name = files[0]['name'] + else: + file_name = [file['name'] for file in files[:5]] # Return up to 5 filenames + + if meta.get('debug'): + console.print(f"[blue]Extracted filename(s): {file_name}[/blue]") # Print the extracted filename(s) + + if imdb != 0: + imdb_str = str(f'tt{imdb}').zfill(7) + else: + imdb_str = None + + console.print(f"[green]Valid IDs found from {tracker}: TMDb: {tmdb}, IMDb: {imdb_str}, TVDb: {tvdb}, MAL: {mal}[/green]") + + if tmdb or imdb or tvdb: + if not id: + # Only prompt the user for ID selection if not searching by ID + try: + if not await self.prompt_user_for_id_selection(meta, tmdb, imdb, tvdb, mal, file_name, tracker_name=tracker): + console.print("[yellow]User chose to skip based on IDs.[/yellow]") + return None, None, None, None, None, None, None, None, None + except (KeyboardInterrupt, EOFError): + sys.exit(1) + + if description: + bbcode = BBCODE() + description, imagelist = bbcode.clean_unit3d_description(description, torrent_url) + if not only_id: + console.print(f"[green]Successfully grabbed description from {tracker}") + console.print(f"Extracted description: {description}", markup=False) + + if meta.get('unattended') or (meta.get('blu') or meta.get('aither') or meta.get('lst') or meta.get('oe') or meta.get('huno') or meta.get('ulcx')): + meta['description'] = description + meta['saved_description'] = True + else: + console.print("[cyan]Do you want to edit, discard or keep the description?[/cyan]") + edit_choice = input("Enter 'e' to edit, 'd' to discard, or press Enter to keep it as is:") + + if edit_choice.lower() == 'e': + edited_description = click.edit(description) + if edited_description: + description = edited_description.strip() + meta['description'] = description + meta['saved_description'] = True + elif edit_choice.lower() == 'd': + description = None + imagelist = [] + console.print("[yellow]Description discarded.[/yellow]") + else: + console.print("[green]Keeping the original description.[/green]") + meta['description'] = description + meta['saved_description'] = True + if not meta.get('keep_images'): + imagelist = [] + else: + description = "" + if not meta.get('keep_images'): + imagelist = [] + + return tmdb, imdb, tvdb, mal, description, category, infohash, imagelist, file_name + + except Exception as e: + console.print_exception() + console.print(f"[yellow]Invalid Response from {tracker} API. Error: {str(e)}[/yellow]") + return None, None, None, None, None, None, None, None, None async def parseCookieFile(self, cookiefile): """Parse a cookies.txt file and return a dictionary of key value pairs compatible with requests.""" cookies = {} - with open (cookiefile, 'r') as fp: + with open(cookiefile, 'r') as fp: for line in fp: if not line.startswith(("# ", "\n", "#\n")): lineFields = re.split(' |\t', line.strip()) @@ -185,45 +1105,43 @@ async def parseCookieFile(self, cookiefile): cookies[lineFields[5]] = lineFields[6] return cookies - - async def ptgen(self, meta, ptgen_site="", ptgen_retry=3): ptgen = "" url = 'https://ptgen.zhenzhen.workers.dev' if ptgen_site != '': url = ptgen_site params = {} - data={} - #get douban url - if int(meta.get('imdb_id', '0')) != 0: + data = {} + # get douban url + if int(meta.get('imdb_id')) != 0: data['search'] = f"tt{meta['imdb_id']}" ptgen = requests.get(url, params=data) - if ptgen.json()["error"] != None: + if ptgen.json()["error"] is not None: for retry in range(ptgen_retry): try: ptgen = requests.get(url, params=params) - if ptgen.json()["error"] == None: + if ptgen.json()["error"] is None: break except requests.exceptions.JSONDecodeError: continue try: - params['url'] = ptgen.json()['data'][0]['link'] + params['url'] = ptgen.json()['data'][0]['link'] except Exception: console.print("[red]Unable to get data from ptgen using IMDb") - params['url'] = console.input(f"[red]Please enter [yellow]Douban[/yellow] link: ") + params['url'] = console.input("[red]Please enter [yellow]Douban[/yellow] link: ") else: console.print("[red]No IMDb id was found.") - params['url'] = console.input(f"[red]Please enter [yellow]Douban[/yellow] link: ") + params['url'] = console.input("[red]Please enter [yellow]Douban[/yellow] link: ") try: ptgen = requests.get(url, params=params) - if ptgen.json()["error"] != None: + if ptgen.json()["error"] is not None: for retry in range(ptgen_retry): ptgen = requests.get(url, params=params) - if ptgen.json()["error"] == None: + if ptgen.json()["error"] is None: break ptgen = ptgen.json() meta['ptgen'] = ptgen - with open (f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: json.dump(meta, f, indent=4) f.close() ptgen = ptgen['format'] @@ -237,105 +1155,306 @@ async def ptgen(self, meta, ptgen_site="", ptgen_retry=3): return "" return ptgen + class MediaInfoParser: + # Language to ISO country code mapping + LANGUAGE_CODE_MAP = { + "afrikaans": ("https://ptpimg.me/i9pt6k.png", "20"), + "albanian": ("https://ptpimg.me/sfhik8.png", "20"), + "amharic": ("https://ptpimg.me/zm816y.png", "20"), + "arabic": ("https://ptpimg.me/5g8i9u.png", "26x10"), + "armenian": ("https://ptpimg.me/zm816y.png", "20"), + "azerbaijani": ("https://ptpimg.me/h3rbe0.png", "20"), + "basque": ("https://ptpimg.me/xj51b9.png", "20"), + "belarusian": ("https://ptpimg.me/iushg1.png", "20"), + "bengali": ("https://ptpimg.me/jq996n.png", "20"), + "bosnian": ("https://ptpimg.me/19t9rv.png", "20"), + "brazilian": ("https://ptpimg.me/p8sgla.png", "20"), + "bulgarian": ("https://ptpimg.me/un9dc6.png", "20"), + "catalan": ("https://ptpimg.me/v4h5bf.png", "20"), + "chinese": ("https://ptpimg.me/ea3yv3.png", "20"), + "croatian": ("https://ptpimg.me/rxi533.png", "20"), + "czech": ("https://ptpimg.me/5m75n3.png", "20"), + "danish": ("https://ptpimg.me/m35c41.png", "20"), + "dutch": ("https://ptpimg.me/6nmwpx.png", "20"), + "dzongkha": ("https://ptpimg.me/56e7y5.png", "20"), + "english": ("https://ptpimg.me/ine2fd.png", "25x10"), + "english (gb)": ("https://ptpimg.me/a9w539.png", "20"), + "estonian": ("https://ptpimg.me/z25pmk.png", "20"), + "filipino": ("https://ptpimg.me/9d3z9w.png", "20"), + "finnish": ("https://ptpimg.me/p4354c.png", "20"), + "french (canada)": ("https://ptpimg.me/ei4s6u.png", "20"), + "french canadian": ("https://ptpimg.me/ei4s6u.png", "20"), + "french": ("https://ptpimg.me/m7mfoi.png", "20"), + "galician": ("https://ptpimg.me/xj51b9.png", "20"), + "georgian": ("https://ptpimg.me/pp412q.png", "20"), + "german": ("https://ptpimg.me/dw8d04.png", "30x10"), + "greek": ("https://ptpimg.me/px1u3e.png", "20"), + "gujarati": ("https://ptpimg.me/d0l479.png", "20"), + "haitian creole": ("https://ptpimg.me/f64wlp.png", "20"), + "hebrew": ("https://ptpimg.me/5jw1jp.png", "20"), + "hindi": ("https://ptpimg.me/d0l479.png", "20"), + "hungarian": ("https://ptpimg.me/fr4aj7.png", "30x10"), + "icelandic": ("https://ptpimg.me/40o553.png", "20"), + "indonesian": ("https://ptpimg.me/f00c8u.png", "20"), + "irish": ("https://ptpimg.me/71x9mk.png", "20"), + "italian": ("https://ptpimg.me/ao762a.png", "20"), + "japanese": ("https://ptpimg.me/o1amm3.png", "20"), + "kannada": ("https://ptpimg.me/d0l479.png", "20"), + "kazakh": ("https://ptpimg.me/tq1h8b.png", "20"), + "khmer": ("https://ptpimg.me/0p1tli.png", "20"), + "korean": ("https://ptpimg.me/2tvwgn.png", "20"), + "kurdish": ("https://ptpimg.me/g290wo.png", "20"), + "kyrgyz": ("https://ptpimg.me/336unh.png", "20"), + "lao": ("https://ptpimg.me/n3nan1.png", "20"), + "latin american": ("https://ptpimg.me/11350x.png", "20"), + "latvian": ("https://ptpimg.me/3x2y1b.png", "25x10"), + "lithuanian": ("https://ptpimg.me/b444z8.png", "20"), + "luxembourgish": ("https://ptpimg.me/52x189.png", "20"), + "macedonian": ("https://ptpimg.me/2g5lva.png", "20"), + "malagasy": ("https://ptpimg.me/n5120r.png", "20"), + "malay": ("https://ptpimg.me/02e17w.png", "30x10"), + "malayalam": ("https://ptpimg.me/d0l479.png", "20"), + "maltese": ("https://ptpimg.me/ua46c2.png", "20"), + "maori": ("https://ptpimg.me/2fw03g.png", "20"), + "marathi": ("https://ptpimg.me/d0l479.png", "20"), + "mongolian": ("https://ptpimg.me/z2h682.png", "20"), + "nepali": ("https://ptpimg.me/5yd3sp.png", "20"), + "norwegian": ("https://ptpimg.me/1t11u4.png", "20"), + "pashto": ("https://ptpimg.me/i9pt6k.png", "20"), + "persian": ("https://ptpimg.me/i0y103.png", "20"), + "polish": ("https://ptpimg.me/m73uwa.png", "20"), + "portuguese": ("https://ptpimg.me/5j1a7q.png", "20"), + "portuguese (brazil)": ("https://ptpimg.me/p8sgla.png", "20"), + "punjabi": ("https://ptpimg.me/d0l479.png", "20"), + "romanian": ("https://ptpimg.me/ux94x0.png", "20"), + "russian": ("https://ptpimg.me/v33j64.png", "20"), + "samoan": ("https://ptpimg.me/8nt3zq.png", "20"), + "serbian": ("https://ptpimg.me/2139p2.png", "20"), + "slovak": ("https://ptpimg.me/70994n.png", "20"), + "slovenian": ("https://ptpimg.me/61yp81.png", "25x10"), + "somali": ("https://ptpimg.me/320pa6.png", "20"), + "spanish": ("https://ptpimg.me/xj51b9.png", "20"), + "spanish (latin america)": ("https://ptpimg.me/11350x.png", "20"), + "swahili": ("https://ptpimg.me/d0l479.png", "20"), + "swedish": ("https://ptpimg.me/082090.png", "20"), + "tamil": ("https://ptpimg.me/d0l479.png", "20"), + "telugu": ("https://ptpimg.me/d0l479.png", "20"), + "thai": ("https://ptpimg.me/38ru43.png", "20"), + "turkish": ("https://ptpimg.me/g4jg39.png", "20"), + "ukrainian": ("https://ptpimg.me/d8fp6k.png", "20"), + "urdu": ("https://ptpimg.me/z23gg5.png", "20"), + "uzbek": ("https://ptpimg.me/89854s.png", "20"), + "vietnamese": ("https://ptpimg.me/qnuya2.png", "20"), + "welsh": ("https://ptpimg.me/a9w539.png", "20"), + "xhosa": ("https://ptpimg.me/7teg09.png", "20"), + "yiddish": ("https://ptpimg.me/5jw1jp.png", "20"), + "yoruba": ("https://ptpimg.me/9l34il.png", "20"), + "zulu": ("https://ptpimg.me/7teg09.png", "20") + } + def parse_mediainfo(self, mediainfo_text): + # Patterns for matching sections and fields + section_pattern = re.compile(r"^(General|Video|Audio|Text|Menu)(?:\s#\d+)?", re.IGNORECASE) + parsed_data = {"general": {}, "video": [], "audio": [], "text": []} + current_section = None + current_track = {} - # async def ptgen(self, meta): - # ptgen = "" - # url = "https://api.iyuu.cn/App.Movie.Ptgen" - # params = {} - # if int(meta.get('imdb_id', '0')) != 0: - # params['url'] = f"tt{meta['imdb_id']}" - # else: - # console.print("[red]No IMDb id was found.") - # params['url'] = console.input(f"[red]Please enter [yellow]Douban[/yellow] link: ") - # try: - # ptgen = requests.get(url, params=params) - # ptgen = ptgen.json() - # ptgen = ptgen['data']['format'] - # if "[/img]" in ptgen: - # ptgen = ptgen.split("[/img]")[1] - # ptgen = f"[img]{meta.get('imdb_info', {}).get('cover', meta.get('cover', ''))}[/img]{ptgen}" - # except: - # console.print_exception() - # console.print("[bold red]There was an error getting the ptgen") - # console.print(ptgen) - # return ptgen - - - - async def filter_dupes(self, dupes, meta): - if meta['debug']: - console.log("[cyan]Pre-filtered dupes") - console.log(dupes) - new_dupes = [] - for each in dupes: - if meta.get('sd', 0) == 1: - remove_set = set() - else: - remove_set = set({meta['resolution']}) - search_combos = [ - { - 'search' : meta['hdr'], - 'search_for' : {'HDR', 'PQ10'}, - 'update' : {'HDR|PQ10'} - }, - { - 'search' : meta['hdr'], - 'search_for' : {'DV'}, - 'update' : {'DV|DoVi'} - }, - { - 'search' : meta['hdr'], - 'search_not' : {'DV', 'DoVi', 'HDR', 'PQ10'}, - 'update' : {'!(DV)|(DoVi)|(HDR)|(PQ10)'} - }, - { - 'search' : str(meta.get('tv_pack', 0)), - 'search_for' : '1', - 'update' : {f"{meta['season']}(?!E\d+)"} - }, - { - 'search' : meta['episode'], - 'search_for' : meta['episode'], - 'update' : {meta['season'], meta['episode']} - } - ] - search_matches = [ - { - 'if' : {'REMUX', 'WEBDL', 'WEBRip', 'HDTV'}, - 'in' : meta['type'] - } - ] - for s in search_combos: - if s.get('search_for') not in (None, ''): - if any(re.search(x, s['search'], flags=re.IGNORECASE) for x in s['search_for']): - remove_set.update(s['update']) - if s.get('search_not') not in (None, ''): - if not any(re.search(x, s['search'], flags=re.IGNORECASE) for x in s['search_not']): - remove_set.update(s['update']) - for sm in search_matches: - for a in sm['if']: - if a in sm['in']: - remove_set.add(a) - - search = each.lower().replace('-', '').replace(' ', '').replace('.', '') - for x in remove_set.copy(): - if "|" in x: - look_for = x.split('|') - for y in look_for: - if y.lower() in search: - if x in remove_set: - remove_set.remove(x) - remove_set.add(y) - - allow = True - for x in remove_set: - if not x.startswith("!"): - if not re.search(x, search, flags=re.I): - allow = False + # Field lists based on PHP definitions + general_fields = {'file_name', 'format', 'duration', 'file_size', 'bit_rate'} + video_fields = { + 'format', 'format_version', 'codec', 'width', 'height', 'stream_size', + 'framerate_mode', 'frame_rate', 'aspect_ratio', 'bit_rate', 'bit_rate_mode', 'bit_rate_nominal', + 'bit_pixel_frame', 'bit_depth', 'language', 'format_profile', + 'color_primaries', 'title', 'scan_type', 'transfer_characteristics', 'hdr_format' + } + audio_fields = { + 'codec', 'format', 'bit_rate', 'channels', 'title', 'language', 'format_profile', 'stream_size' + } + # text_fields = {'title', 'language'} + + # Split MediaInfo by lines and process each line + for line in mediainfo_text.splitlines(): + line = line.strip() + + # Detect a new section + section_match = section_pattern.match(line) + if section_match: + # Save the last track data if moving to a new section + if current_section and current_track: + if current_section in ["video", "audio", "text"]: + parsed_data[current_section].append(current_track) + else: + parsed_data[current_section] = current_track + # Debug output for finalizing the current track data + # print(f"Final processed track data for section '{current_section}': {current_track}") + current_track = {} # Reset current track + + # Update the current section + current_section = section_match.group(1).lower() + continue + + # Split each line on the first colon to separate property and value + if ":" in line: + property_name, property_value = map(str.strip, line.split(":", 1)) + property_name = property_name.lower().replace(" ", "_") + + # Add property if it's a recognized field for the current section + if current_section == "general" and property_name in general_fields: + current_track[property_name] = property_value + elif current_section == "video" and property_name in video_fields: + current_track[property_name] = property_value + elif current_section == "audio" and property_name in audio_fields: + current_track[property_name] = property_value + elif current_section == "text": + # Processing specific properties for text + # Process title field + if property_name == "title" and "title" not in current_track: + title_lower = property_value.lower() + # print(f"\nProcessing Title: '{property_value}'") # Debugging output + + # Store the title as-is since it should remain descriptive + current_track["title"] = property_value + # print(f"Stored title: '{property_value}'") + + # If there's an exact match in LANGUAGE_CODE_MAP, add country code to language field + if title_lower in self.LANGUAGE_CODE_MAP: + country_code, size = self.LANGUAGE_CODE_MAP[title_lower] + current_track["language"] = f"[img={size}]{country_code}[/img]" + # print(f"Exact match found for title '{title_lower}' with country code: {country_code}") + + # Process language field only if it hasn't already been set + elif property_name == "language" and "language" not in current_track: + language_lower = property_value.lower() + # print(f"\nProcessing Language: '{property_value}'") # Debugging output + + if language_lower in self.LANGUAGE_CODE_MAP: + country_code, size = self.LANGUAGE_CODE_MAP[language_lower] + current_track["language"] = f"[img={size}]{country_code}[/img]" + # print(f"Matched language '{language_lower}' to country code: {country_code}") + else: + # If no match in LANGUAGE_CODE_MAP, store language as-is + current_track["language"] = property_value + # print(f"No match found for language '{property_value}', stored as-is.") + + # Append the last track to the parsed data if it exists + if current_section and current_track: + if current_section in ["video", "audio", "text"]: + parsed_data[current_section].append(current_track) + else: + parsed_data[current_section] = current_track + # Final debug output for the last track data + # print(f"Final processed track data for last section '{current_section}': {current_track}") + + # Debug output for the complete parsed_data + # print("\nComplete Parsed Data:") + # for section, data in parsed_data.items(): + # print(f"{section}: {data}") + + return parsed_data + + def format_bbcode(self, parsed_mediainfo): + bbcode_output = "\n" + + # Format General Section + if "general" in parsed_mediainfo: + bbcode_output += "[b]General[/b]\n" + for prop, value in parsed_mediainfo["general"].items(): + bbcode_output += f"[b]{prop.replace('_', ' ').capitalize()}:[/b] {value}\n" + + # Format Video Section + if "video" in parsed_mediainfo: + bbcode_output += "\n[b]Video[/b]\n" + for track in parsed_mediainfo["video"]: + for prop, value in track.items(): + bbcode_output += f"[b]{prop.replace('_', ' ').capitalize()}:[/b] {value}\n" + + # Format Audio Section + if "audio" in parsed_mediainfo: + bbcode_output += "\n[b]Audio[/b]\n" + for index, track in enumerate(parsed_mediainfo["audio"], start=1): # Start enumeration at 1 + parts = [f"{index}."] # Start with track number without a trailing slash + + # Language flag image + language = track.get("language", "").lower() + result = self.LANGUAGE_CODE_MAP.get(language) + + # Check if the language was found in LANGUAGE_CODE_MAP + if result is not None: + country_code, size = result + parts.append(f"[img={size}]{country_code}[/img]") + else: + # If language is not found, use a fallback or display the language as plain text + parts.append(language.capitalize() if language else "") + + # Other properties to concatenate + properties = ["language", "codec", "format", "channels", "bit_rate", "format_profile", "stream_size"] + for prop in properties: + if prop in track and track[prop]: # Only add non-empty properties + parts.append(track[prop]) + + # Join parts (starting from index 1, after the track number) with slashes and add to bbcode_output + bbcode_output += f"{parts[0]} " + " / ".join(parts[1:]) + "\n" + + # Format Text Section - Centered with flags or text, spaced apart + if "text" in parsed_mediainfo: + bbcode_output += "\n[b]Subtitles[/b]\n" + subtitle_entries = [] + for track in parsed_mediainfo["text"]: + language_display = track.get("language", "") + subtitle_entries.append(language_display) + bbcode_output += " ".join(subtitle_entries) + + bbcode_output += "\n" + return bbcode_output + + def get_version(self): + current_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(os.path.dirname(current_dir)) + version_file_path = os.path.join(project_root, 'data', 'version.py') + if not os.path.isfile(version_file_path): + return '' + try: + with open(version_file_path, "r", encoding="utf-8") as f: + content = f.read() + match = re.search(r'__version__\s*=\s*"([^"]+)"', content) + if match: + return match.group(1) + except OSError as e: + print(f"Error reading version file: {e}") + return '' + + return '' + + async def get_bdmv_mediainfo(self, meta, remove=None): + mediainfo = '' + mi_path = f'{meta["base_dir"]}/tmp/{meta["uuid"]}/MEDIAINFO_CLEANPATH.txt' + + if meta.get('is_disc') == 'BDMV': + path = meta['discs'][0]['playlists'][0]['path'] + await exportInfo( + path, + False, + meta['uuid'], + meta['base_dir'], + export_text=True, + is_dvd=False, + debug=meta.get('debug', False) + ) + + async with aiofiles.open(mi_path, 'r', encoding='utf-8') as f: + lines = await f.readlines() + + if remove: + if not isinstance(remove, list): + lines_to_remove = [remove] else: - if re.search(x.replace("!", "", 1), search, flags=re.I) not in (None, False): - allow = False - if allow and each not in new_dupes: - new_dupes.append(each) - return new_dupes + lines_to_remove = remove + + lines = [ + line for line in lines + if not any(line.strip().startswith(prefix) for prefix in lines_to_remove) + ] + + mediainfo = ''.join(lines) if remove else lines + + return mediainfo diff --git a/src/trackers/CZ.py b/src/trackers/CZ.py new file mode 100644 index 000000000..26df5e8cd --- /dev/null +++ b/src/trackers/CZ.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +from datetime import datetime +from src.trackers.COMMON import COMMON +from src.trackers.AVISTAZ_NETWORK import AZTrackerBase + + +class CZ(AZTrackerBase): + def __init__(self, config): + super().__init__(config, tracker_name='CZ') + self.config = config + self.common = COMMON(config) + self.tracker = 'CZ' + self.source_flag = 'CinemaZ' + self.banned_groups = [''] + self.base_url = 'https://cinemaz.to' + self.torrent_url = f'{self.base_url}/torrent/' + + async def rules(self, meta): + warnings = [] + + image_links = [img.get('raw_url') for img in meta.get('image_list', []) if img.get('raw_url')] + if len(image_links) < 3: + warnings.append(f'{self.tracker}: At least 3 screenshots are required to upload.') + + # This also checks the rule 'FANRES content is not allowed' + if meta['category'] not in ('MOVIE', 'TV'): + warnings.append( + 'The only allowed content to be uploaded are Movies and TV Shows.\n' + 'Anything else, like games, music, software and porn is not allowed!' + ) + + if meta.get('anime', False): + warnings.append("Upload Anime content to our sister site AnimeTorrents.me instead. If it's on AniDB, it's an anime.") + + # https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes + + africa = [ + 'AO', 'BF', 'BI', 'BJ', 'BW', 'CD', 'CF', 'CG', 'CI', 'CM', 'CV', 'DJ', 'DZ', 'EG', 'EH', + 'ER', 'ET', 'GA', 'GH', 'GM', 'GN', 'GQ', 'GW', 'IO', 'KE', 'KM', 'LR', 'LS', 'LY', 'MA', + 'MG', 'ML', 'MR', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'RE', 'RW', 'SC', 'SD', 'SH', 'SL', + 'SN', 'SO', 'SS', 'ST', 'SZ', 'TD', 'TF', 'TG', 'TN', 'TZ', 'UG', 'YT', 'ZA', 'ZM', 'ZW' + ] + + america = [ + 'AG', 'AI', 'AR', 'AW', 'BB', 'BL', 'BM', 'BO', 'BQ', 'BR', 'BS', 'BV', 'BZ', 'CA', 'CL', + 'CO', 'CR', 'CU', 'CW', 'DM', 'DO', 'EC', 'FK', 'GD', 'GF', 'GL', 'GP', 'GS', 'GT', 'GY', + 'HN', 'HT', 'JM', 'KN', 'KY', 'LC', 'MF', 'MQ', 'MS', 'MX', 'NI', 'PA', 'PE', 'PM', 'PR', + 'PY', 'SR', 'SV', 'SX', 'TC', 'TT', 'US', 'UY', 'VC', 'VE', 'VG', 'VI' + ] + + europe = [ + 'AD', 'AL', 'AT', 'AX', 'BA', 'BE', 'BG', 'BY', 'CH', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', + 'FO', 'FR', 'GB', 'GG', 'GI', 'GR', 'HR', 'HU', 'IE', 'IM', 'IS', 'IT', 'JE', 'LI', 'LT', + 'LU', 'LV', 'MC', 'MD', 'ME', 'MK', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'RS', 'RU', 'SE', + 'SI', 'SJ', 'SK', 'SM', 'SU', 'UA', 'VA', 'XC' + ] + + # Countries that belong on PrivateHD (unless they are old) + phd_countries = [ + 'AG', 'AI', 'AU', 'BB', 'BM', 'BS', 'BZ', 'CA', 'CW', 'DM', 'GB', 'GD', 'IE', + 'JM', 'KN', 'KY', 'LC', 'MS', 'NZ', 'PR', 'TC', 'TT', 'US', 'VC', 'VG', 'VI', + ] + + # Countries that belong on AvistaZ + az_countries = [ + 'BD', 'BN', 'BT', 'CN', 'HK', 'ID', 'IN', 'JP', 'KH', 'KP', 'KR', 'LA', 'LK', + 'MM', 'MN', 'MO', 'MY', 'NP', 'PH', 'PK', 'SG', 'TH', 'TL', 'TW', 'VN' + ] + + # Countries normally allowed on CinemaZ + set_phd = set(phd_countries) + set_europe = set(europe) + set_america = set(america) + middle_east = [ + 'AE', 'BH', 'CY', 'EG', 'IR', 'IQ', 'IL', 'JO', 'KW', 'LB', 'OM', 'PS', 'QA', 'SA', 'SY', 'TR', 'YE' + ] + + # Combine all allowed regions for CinemaZ + cz_allowed_countries = list( + (set_europe - {'GB', 'IE'}) | # Europe excluding UK and Ireland + (set_america - set_phd) | # All of America excluding the PHD countries + set(africa) | # All of Africa + set(middle_east) | # Middle East countries + {'RU'} # Russia + ) + + origin_countries_codes = meta.get('origin_country', []) + year = meta.get('year') + is_older_than_50_years = False + + if isinstance(year, int): + current_year = datetime.now().year + if (current_year - year) >= 50: + is_older_than_50_years = True + + # Case 1: The content is from a major English-speaking country + if any(code in phd_countries for code in origin_countries_codes): + if is_older_than_50_years: + # It's old, so it's ALLOWED on CinemaZ + pass + else: + # It's new, so redirect to PrivateHD + warnings.append( + 'DO NOT upload recent mainstream English content. ' + 'Upload this to our sister site PrivateHD.to instead.' + ) + + # Case 2: The content is Asian, redirect to AvistaZ + elif any(code in az_countries for code in origin_countries_codes): + warnings.append( + 'DO NOT upload Asian content. ' + 'Upload this to our sister site AvistaZ.to instead.' + ) + + # Case 3: The content is from one of the normally allowed CZ regions + elif any(code in cz_allowed_countries for code in origin_countries_codes): + # It's from a valid region, so it's ALLOWED on CinemaZ + pass + + # Case 4: Fallback for any other case (e.g., country not in any list) + else: + warnings.append( + 'This content is not allowed. CinemaZ accepts content from Europe (excluding UK/IE), ' + 'Africa, the Middle East, Russia, and the Americas (excluding recent mainstream English content).' + ) + + if warnings: + all_warnings = '\n\n'.join(filter(None, warnings)) + return all_warnings + + return diff --git a/src/trackers/DC.py b/src/trackers/DC.py new file mode 100644 index 000000000..6665da827 --- /dev/null +++ b/src/trackers/DC.py @@ -0,0 +1,311 @@ +# -*- coding: utf-8 -*- +import httpx +import os +import re +from src.trackers.COMMON import COMMON +from src.console import console +from src.rehostimages import check_hosts + + +class DC(): + def __init__(self, config): + self.config = config + self.common = COMMON(config) + self.tracker = 'DC' + self.source_flag = 'DigitalCore.club' + self.base_url = 'https://digitalcore.club' + self.torrent_url = f'{self.base_url}/torrent/' + self.api_base_url = f'{self.base_url}/api/v1' + self.banned_groups = [''] + self.api_key = self.config['TRACKERS'][self.tracker].get('api_key') + self.passkey = self.config['TRACKERS'][self.tracker].get('passkey') + self.announce_list = [ + f'https://tracker.digitalcore.club/announce/{self.passkey}', + f'https://trackerprxy.digitalcore.club/announce/{self.passkey}' + ] + self.session = httpx.AsyncClient(headers={ + 'X-API-KEY': self.api_key + }, timeout=30.0) + self.signature = "[center][url=https://github.com/Audionut/Upload-Assistant]Created by Upload Assistant[/url][/center]" + + async def mediainfo(self, meta): + if meta.get('is_disc') == 'BDMV': + mediainfo = await self.common.get_bdmv_mediainfo(meta, remove=['File size', 'Overall bit rate']) + else: + mi_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + with open(mi_path, 'r', encoding='utf-8') as f: + mediainfo = f.read() + + return mediainfo + + async def generate_description(self, meta): + base_desc = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + dc_desc = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + description_parts = [] + + # BDInfo + tech_info = '' + if meta.get('is_disc') == 'BDMV': + bd_summary_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt" + if os.path.exists(bd_summary_file): + with open(bd_summary_file, 'r', encoding='utf-8') as f: + tech_info = f.read() + + if tech_info: + description_parts.append(f'{tech_info}') + + if os.path.exists(base_desc): + with open(base_desc, 'r', encoding='utf-8') as f: + manual_desc = f.read() + description_parts.append(manual_desc) + + # Screenshots + if f'{self.tracker}_images_key' in meta: + images = meta[f'{self.tracker}_images_key'] + else: + images = meta['image_list'] + if images: + screenshots_block = '[center]\n' + for i, image in enumerate(images, start=1): + img_url = image['img_url'] + web_url = image['web_url'] + screenshots_block += f'[url={web_url}][img=350]{img_url}[/img][/url] ' + # limits to 2 screens per line, as the description box is small + if i % 2 == 0: + screenshots_block += '\n' + screenshots_block += '\n[/center]' + description_parts.append(screenshots_block) + + custom_description_header = self.config['DEFAULT'].get('custom_description_header', '') + if custom_description_header: + description_parts.append(custom_description_header) + + if self.signature: + description_parts.append(self.signature) + + final_description = '\n\n'.join(filter(None, description_parts)) + from src.bbcode import BBCODE + bbcode = BBCODE() + desc = final_description + desc = desc.replace('[user]', '').replace('[/user]', '') + desc = desc.replace('[align=left]', '').replace('[/align]', '') + desc = desc.replace('[right]', '').replace('[/right]', '') + desc = desc.replace('[align=right]', '').replace('[/align]', '') + desc = desc.replace('[sup]', '').replace('[/sup]', '') + desc = desc.replace('[sub]', '').replace('[/sub]', '') + desc = desc.replace('[alert]', '').replace('[/alert]', '') + desc = desc.replace('[note]', '').replace('[/note]', '') + desc = desc.replace('[hr]', '').replace('[/hr]', '') + desc = desc.replace('[h1]', '[u][b]').replace('[/h1]', '[/b][/u]') + desc = desc.replace('[h2]', '[u][b]').replace('[/h2]', '[/b][/u]') + desc = desc.replace('[h3]', '[u][b]').replace('[/h3]', '[/b][/u]') + desc = desc.replace('[ul]', '').replace('[/ul]', '') + desc = desc.replace('[ol]', '').replace('[/ol]', '') + desc = re.sub(r'\[center\]\[spoiler=.*? NFO:\]\[code\](.*?)\[/code\]\[/spoiler\]\[/center\]', r'[nfo]\1[/nfo]', desc, flags=re.DOTALL) + desc = re.sub(r'\[img(?!=\d+)[^\]]*\]', '[img]', desc, flags=re.IGNORECASE) + desc = re.sub(r'(\[spoiler=[^]]+])', '[spoiler]', desc, flags=re.IGNORECASE) + desc = bbcode.convert_comparison_to_centered(desc, 1000) + desc = re.sub(r'\n{3,}', '\n\n', desc) + + with open(dc_desc, 'w', encoding='utf-8') as f: + f.write(desc) + + return desc + + async def get_category_id(self, meta): + resolution = meta.get('resolution', '') + category = meta.get('category', '') + is_disc = meta.get('is_disc', '') + tv_pack = meta.get('tv_pack', '') + sd = meta.get('sd', '') + + if is_disc == 'BDMV': + if resolution == '1080p' and category == 'MOVIE': + return 3 + elif resolution == '2160p' and category == 'MOVIE': + return 38 + elif category == 'TV': + return 14 + if is_disc == 'DVD': + if category == 'MOVIE': + return 1 + elif category == 'TV': + return 11 + if category == 'TV' and tv_pack == 1: + return 12 + if sd == 1: + if category == 'MOVIE': + return 2 + elif category == 'TV': + return 10 + category_map = { + 'MOVIE': {'2160p': 4, '1080p': 6, '1080i': 6, '720p': 5}, + 'TV': {'2160p': 13, '1080p': 9, '1080i': 9, '720p': 8}, + } + if category in category_map: + return category_map[category].get(resolution) + return None + + async def login(self): + if not self.api_key: + console.print(f'[bold red]API key for {self.tracker} is not configured.[/bold red]') + return False + + url = f'{self.api_base_url}/torrents' + + try: + response = await self.session.get(url, headers=self.session.headers, timeout=15) + if response.status_code == 200: + return True + else: + console.print(f'[bold red]Authentication failed for {self.tracker}. Status: {response.status_code}[/bold red]') + return False + except httpx.RequestError as e: + console.print(f'[bold red]Error during {self.tracker} authentication: {e}[/bold red]') + return False + + async def search_existing(self, meta, results): + imdb_id = meta.get('imdb_info', {}).get('imdbID') + if not imdb_id: + console.print(f'[bold yellow]Cannot perform search on {self.tracker}: IMDb ID not found in metadata.[/bold yellow]') + return [] + + search_url = f'{self.api_base_url}/torrents' + search_params = {'searchText': imdb_id} + search_results = [] + dupes = [] + try: + response = await self.session.get(search_url, params=search_params, headers=self.session.headers, timeout=15) + response.raise_for_status() + + if response.text and response.text != '[]': + search_results = response.json() + results = search_results + if search_results and isinstance(search_results, list): + should_continue = await self.get_title(meta, results) + if not should_continue: + print('An UNRAR duplicate of this specific release already exists on site.') + meta['skipping'] = f'{self.tracker}' + return + + for each in search_results: + name = each.get('name') + torrent_id = each.get('id') + size = each.get('size') + torrent_link = f'{self.torrent_url}{torrent_id}/' if torrent_id else None + dupe_entry = { + 'name': name, + 'size': size, + 'link': torrent_link + } + dupes.append(dupe_entry) + + return dupes + + except Exception as e: + console.print(f'[bold red]Error searching for IMDb ID {imdb_id} on {self.tracker}: {e}[/bold red]') + + return [] + + async def get_title(self, meta, results): + results = results + is_scene = bool(meta.get('scene_name')) + base_name = meta['scene_name'] if is_scene else meta['uuid'] + + needs_unrar_tag = False + + if results: + upload_title = {meta['uuid']} + if is_scene: + upload_title.add(meta['scene_name']) + + matching_titles = [ + t for t in results + if t.get('name') in upload_title + ] + + if matching_titles: + unrar_version_exists = any(t.get('unrar', 0) != 0 for t in matching_titles) + + if unrar_version_exists: + return False + else: + console.print(f'[bold yellow]Found a RAR version of this release on {self.tracker}. Appending [UNRAR] to filename.[/bold yellow]') + needs_unrar_tag = True + + if needs_unrar_tag: + upload_base_name = meta['scene_name'] if is_scene else meta['uuid'] + title = f'{upload_base_name} [UNRAR].torrent' + else: + title = f'{base_name}.torrent' + + container = '.' + meta.get('container', 'mkv') + title = title.replace(container, '').replace(container.upper(), '') + + return title + + async def fetch_data(self, meta): + approved_image_hosts = ['imgbox', 'imgbb', 'bhd', 'imgur', 'postimg', 'digitalcore'] + url_host_mapping = { + 'ibb.co': 'imgbb', + 'imgbox.com': 'imgbox', + 'beyondhd.co': 'bhd', + 'imgur.com': 'imgur', + 'postimg.cc': 'postimg', + 'digitalcore.club': 'digitalcore' + } + await check_hosts(meta, self.tracker, url_host_mapping=url_host_mapping, img_host_index=1, approved_image_hosts=approved_image_hosts) + + anon = '1' if meta['anon'] or self.config['TRACKERS'][self.tracker].get('anon', False) else '0' + + data = { + 'category': await self.get_category_id(meta), + 'imdbId': meta.get('imdb_info', {}).get('imdbID', ''), + 'nfo': await self.generate_description(meta), + 'mediainfo': await self.mediainfo(meta), + 'reqid': '0', + 'section': 'new', + 'frileech': '1', + 'anonymousUpload': anon, + 'p2p': '0', + 'unrar': '1', + } + + return data + + async def upload(self, meta, results): + await self.common.edit_torrent(meta, self.tracker, self.source_flag) + data = await self.fetch_data(meta) + title = await self.get_title(meta, results) + status_message = '' + torrent_id = '' + + if not meta.get('debug', False): + upload_url = f'{self.api_base_url}/torrents/upload' + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = {'file': (title, torrent_file, 'application/x-bittorrent')} + + response = await self.session.post(upload_url, data=data, files=files, headers=self.session.headers, timeout=90) + response.raise_for_status() + status_message = response.json() + + if response.status_code == 200 and status_message.get('id'): + torrent_id = str(status_message.get('id', '')) + if torrent_id: + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + else: + console.print(f"{status_message.get('message', 'Unknown API error.')}") + meta['skipping'] = f"{self.tracker}" + return + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading' + + await self.common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce_list, self.torrent_url + torrent_id + '/') + + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/DP.py b/src/trackers/DP.py new file mode 100644 index 000000000..8f4db5a59 --- /dev/null +++ b/src/trackers/DP.py @@ -0,0 +1,98 @@ +# -*- coding: utf-8 -*- +# import discord +import aiofiles +import cli_ui +import re +from data.config import config +from src.console import console +from src.languages import process_desc_language +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class DP(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='DP') + self.config = config + self.common = COMMON(config) + self.tracker = 'DP' + self.source_flag = 'DarkPeers' + self.base_url = 'https://darkpeers.org' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + 'aXXo', 'BANDOLEROS', 'BONE', 'BRrip', 'CM8', 'CrEwSaDe', 'CTFOH', 'dAV1nci', 'DNL', + 'FaNGDiNG0', 'FiSTER', 'GalaxyTV', 'HD2DVD', 'HDT', 'HDTime', 'iHYTECH', 'ION10', + 'iPlanet', 'KiNGDOM', 'LAMA', 'MeGusta', 'mHD', 'mSD', 'NaNi', 'NhaNc3', 'nHD', + 'nikt0', 'nSD', 'OFT', 'PiTBULL', 'PRODJi', 'RARBG', 'Rifftrax', 'ROCKETRACCOON', + 'SANTi', 'SasukeducK', 'SEEDSTER', 'ShAaNiG', 'Sicario', 'STUTTERSHIT', 'TAoE', + 'TGALAXY', 'TGx', 'TORRENTGALAXY', 'ToVaR', 'TSP', 'TSPxL', 'ViSION', 'VXT', + 'WAF', 'WKS', 'X0r', 'YIFY', 'YTS', ['EVO', 'WEB-DL only'] + ] + pass + + async def get_additional_checks(self, meta): + should_continue = True + if meta.get('keep_folder'): + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print(f'[bold red]{self.tracker} does not allow single files in a folder.') + if cli_ui.ask_yes_no("Do you want to upload anyway?", default=False): + pass + else: + meta['skipping'] = {self.tracker} + return False + else: + meta['skipping'] = {self.tracker} + return False + if not meta.get('language_checked', False): + console.print(f"[yellow]Language not checked yet, processing description for {self.tracker}.[/yellow]") + await process_desc_language(meta, desc=None, tracker=self.tracker) + nordic_languages = ['Danish', 'Swedish', 'Norwegian', 'Icelandic', 'Finnish', 'English'] + if not any(lang in meta.get('audio_languages', []) for lang in nordic_languages) and not any(lang in meta.get('subtitle_languages', []) for lang in nordic_languages): + console.print(f'[bold red]{self.tracker} requires at least one Nordic/English audio or subtitle track.') + meta['skipping'] = {self.tracker} + return False + + if meta['type'] == "ENCODE" and meta.get('tag', "") and 'fgt' in meta['tag'].lower(): + if not meta['unattended']: + console.print(f"[bold red]{self.tracker} does not allow FGT encodes, skipping upload.") + meta['skipping'] = {self.tracker} + return False + + return should_continue + + async def get_description(self, meta): + if meta.get('logo', "") == "": + from src.tmdb import get_logo + TMDB_API_KEY = config['DEFAULT'].get('tmdb_api', False) + TMDB_BASE_URL = "https://api.themoviedb.org/3" + tmdb_id = meta.get('tmdb') + category = meta.get('category') + debug = meta.get('debug') + logo_languages = ['da', 'sv', 'no', 'fi', 'is', 'en'] + logo_path = await get_logo(tmdb_id, category, debug, logo_languages=logo_languages, TMDB_API_KEY=TMDB_API_KEY, TMDB_BASE_URL=TMDB_BASE_URL) + if logo_path: + meta['logo'] = logo_path + await self.common.unit3d_edit_desc(meta, self.tracker, self.signature) + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8') as f: + desc = await f.read() + return {'description': desc} + + async def get_additional_data(self, meta): + data = { + 'mod_queue_opt_in': await self.get_flag(meta, 'modq'), + } + + return data + + async def get_name(self, meta): + dp_name = meta.get('name') + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + tag_lower = meta['tag'].lower() + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + dp_name = re.sub(f"-{invalid_tag}", "", dp_name, flags=re.IGNORECASE) + dp_name = f"{dp_name}-NOGROUP" + return {'name': dp_name} diff --git a/src/trackers/FF.py b/src/trackers/FF.py new file mode 100644 index 000000000..da4adc4cc --- /dev/null +++ b/src/trackers/FF.py @@ -0,0 +1,646 @@ +# -*- coding: utf-8 -*- +import asyncio +import glob +import httpx +import os +import platform +import re +import unicodedata +from .COMMON import COMMON +from bs4 import BeautifulSoup +from pymediainfo import MediaInfo +from src.console import console +from src.languages import process_desc_language + + +class FF(COMMON): + def __init__(self, config): + super().__init__(config) + self.tracker = "FF" + self.banned_groups = [""] + self.source_flag = "FunFile" + self.base_url = "https://www.funfile.org" + self.torrent_url = "https://www.funfile.org/details.php?id=" + self.announce = self.config['TRACKERS'][self.tracker]['announce_url'] + self.auth_token = None + self.session = httpx.AsyncClient(headers={ + 'User-Agent': f"Upload Assistant/2.3 ({platform.system()} {platform.release()})" + }, timeout=30.0) + self.signature = "[center][url=https://github.com/Audionut/Upload-Assistant]Created by Upload Assistant[/url][/center]" + + async def validate_credentials(self, meta): + self.cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + if not os.path.exists(self.cookie_file): + await self.login(meta) + + test_url = f"{self.base_url}/upload.php" + try: + self.session.cookies.update(await self.parseCookieFile(self.cookie_file)) + response = await self.session.get(test_url, timeout=30) + + if response.status_code == 200 and 'login.php' not in str(response.url): + return True + else: + await self.login(meta) + response = await self.session.get(test_url, timeout=30) + if response.status_code == 200 and 'login.php' not in str(response.url): + return True + else: + return False + except Exception as e: + console.print(f"[bold red]{self.tracker}: Error validating credentials: {e}[/bold red]") + return False + + async def login(self, meta): + login_url = "https://www.funfile.org/takelogin.php" + self.cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + + payload = { + "returnto": "/index.php", + "username": self.config['TRACKERS'][self.tracker]['username'], + "password": self.config['TRACKERS'][self.tracker]['password'], + "login": "Login" + } + + print(f"[{self.tracker}] Trying to login...") + response = await self.session.post(login_url, data=payload) + + if response.status_code == 302: + print(f"[{self.tracker}] Login Successful!") + + with open(self.cookie_file, "w") as f: + f.write("# Netscape HTTP Cookie File\n") + f.write("# This file was generated by an automated script.\n\n") + for cookie in self.session.cookies.jar: + domain = cookie.domain + include_subdomains = "TRUE" if domain.startswith('.') else "FALSE" + path = cookie.path + secure = "TRUE" if cookie.secure else "FALSE" + expires = str(int(cookie.expires)) if cookie.expires else "0" + name = cookie.name + value = cookie.value + f.write(f"{domain}\t{include_subdomains}\t{path}\t{secure}\t{expires}\t{name}\t{value}\n") + print(f"[{self.tracker}] Saving the cookie file...") + else: + print(f"[{self.tracker}] Login failed. Status code: {response.status_code}") + + async def search_existing(self, meta, disctype): + if meta['category'] == 'MOVIE': + query = meta['title'] + if meta['category'] == 'TV': + query = f"{meta['title']} {meta.get('season', '')}{meta.get('episode', '')}" + + search_url = f"{self.base_url}/suggest.php?q={query}" + response = await self.session.get(search_url) + + if response.status_code == 200 and 'login' not in str(response.url): + items = [line.strip() for line in response.text.splitlines() if line.strip()] + return items + + return [] + + async def get_requests(self, meta): + if self.config['TRACKERS'][self.tracker].get('check_requests', False) is False: + return False + + else: + try: + category = self.get_type_id(meta) + + query_1 = meta['title'] + query_2 = meta['title'].replace(' ', '.') + + search_url_1 = f"{self.base_url}/requests.php?filter=open&category={category}&search={query_1}" + + if query_1 != query_2: + search_url_2 = f"{self.base_url}/requests.php?filter=open&category={category}&search={query_2}" + responses = await asyncio.gather( + self.session.get(search_url_1), + self.session.get(search_url_2) + ) + response_results_text = responses[0].text + responses[1].text + responses[0].raise_for_status() + responses[1].raise_for_status() + else: + response = await self.session.get(search_url_1) + response.raise_for_status() + response_results_text = response.text + + soup = BeautifulSoup(response_results_text, "html.parser") + request_rows = soup.select("td.mf_content table tr") + + results = [] + for row in request_rows: + name_element = row.select_one("td.row3 nobr a b") + if not name_element: + continue + + name = name_element.text.strip() + link_element = name_element.find_parent("a") + link = link_element["href"] if link_element else None + + all_tds = row.find_all("td", class_="row3") + reward = all_tds[2].text.strip() if len(all_tds) > 2 else None + + results.append({ + "Name": name, + "Link": link, + "Reward": reward + }) + + if results: + message = f"\n{self.tracker}: [bold yellow]Your upload may fulfill the following request(s), check it out:[/bold yellow]\n\n" + for r in results: + message += f"[bold green]Name:[/bold green] {r['Name']}\n" + message += f"[bold green]Reward:[/bold green] {r['Reward']}\n" + message += f"[bold green]Link:[/bold green] {r['Link']}\n\n" + console.print(message) + + return results + + except Exception as e: + print(f"An error occurred while fetching requests: {e}") + return [] + + async def generate_description(self, meta): + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + description_parts = [] + + # MediaInfo/BDInfo + tech_info = "" + if meta.get('is_disc') != 'BDMV': + video_file = meta['filelist'][0] + mi_template = os.path.abspath(f"{meta['base_dir']}/data/templates/MEDIAINFO.txt") + if os.path.exists(mi_template): + try: + media_info = MediaInfo.parse(video_file, output="STRING", full=False, mediainfo_options={"inform": f"file://{mi_template}"}) + tech_info = str(media_info) + except Exception: + console.print("[bold red]Couldn't find the MediaInfo template[/bold red]") + mi_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + if os.path.exists(mi_file_path): + with open(mi_file_path, 'r', encoding='utf-8') as f: + tech_info = f.read() + else: + console.print("[bold yellow]Using normal MediaInfo for the description.[/bold yellow]") + mi_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + if os.path.exists(mi_file_path): + with open(mi_file_path, 'r', encoding='utf-8') as f: + tech_info = f.read() + else: + bd_summary_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt" + if os.path.exists(bd_summary_file): + with open(bd_summary_file, 'r', encoding='utf-8') as f: + tech_info = f.read() + + if tech_info: + description_parts.append(tech_info) + + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as f: + manual_desc = f.read() + description_parts.append(manual_desc) + + # Screenshots + images = meta.get('image_list', []) + if images: + screenshots_block = "[center]" + for image in images: + img_url = image['img_url'] + web_url = image['web_url'] + screenshots_block += f' ' + screenshots_block += "[/center]" + + description_parts.append(screenshots_block) + + custom_description_header = self.config['DEFAULT'].get('custom_description_header', '') + if custom_description_header: + description_parts.append(custom_description_header) + + if self.signature: + description_parts.append(self.signature) + + final_description = "\n\n".join(filter(None, description_parts)) + from src.bbcode import BBCODE + bbcode = BBCODE() + desc = final_description + desc = desc.replace("[user]", "").replace("[/user]", "") + desc = desc.replace("[align=left]", "").replace("[/align]", "") + desc = desc.replace("[right]", "").replace("[/right]", "") + desc = desc.replace("[align=right]", "").replace("[/align]", "") + desc = desc.replace("[sup]", "").replace("[/sup]", "") + desc = desc.replace("[sub]", "").replace("[/sub]", "") + desc = desc.replace("[alert]", "").replace("[/alert]", "") + desc = desc.replace("[note]", "").replace("[/note]", "") + desc = desc.replace("[hr]", "").replace("[/hr]", "") + desc = desc.replace("[h1]", "[u][b]").replace("[/h1]", "[/b][/u]") + desc = desc.replace("[h2]", "[u][b]").replace("[/h2]", "[/b][/u]") + desc = desc.replace("[h3]", "[u][b]").replace("[/h3]", "[/b][/u]") + desc = desc.replace("[ul]", "").replace("[/ul]", "") + desc = desc.replace("[ol]", "").replace("[/ol]", "") + desc = desc.replace("[hide]", "").replace("[/hide]", "") + desc = desc.replace("•", "-").replace("“", '"').replace("”", '"') + desc = re.sub(r"\[center\]\[spoiler=.*? NFO:\]\[code\](.*?)\[/code\]\[/spoiler\]\[/center\]", r"", desc, flags=re.DOTALL) + desc = bbcode.convert_comparison_to_centered(desc, 1000) + desc = bbcode.remove_spoiler(desc) + + # [url][img=000]...[/img][/url] + desc = re.sub( + r"\[url=(?P[^\]]+)\]\[img=(?P\d+)\](?P[^\[]+)\[/img\]\[/url\]", + r'', + desc, + flags=re.IGNORECASE + ) + + # [url][img]...[/img][/url] + desc = re.sub( + r"\[url=(?P[^\]]+)\]\[img\](?P[^\[]+)\[/img\]\[/url\]", + r'', + desc, + flags=re.IGNORECASE + ) + + # [img=200]...[/img] (no [url]) + desc = re.sub( + r"\[img=(?P\d+)\](?P[^\[]+)\[/img\]", + r'', + desc, + flags=re.IGNORECASE + ) + + desc = re.sub(r'\n{3,}', '\n\n', desc) + + with open(final_desc_path, 'w', encoding='utf-8') as f: + f.write(desc) + + return desc.encode("utf-8") + + def get_type_id(self, meta): + if meta.get('anime'): + return '44' + category = meta['category'] + + if category == 'MOVIE': + return '19' + + elif category == 'TV': + return '7' + + def file_information(self, meta): + vc = meta.get('video_codec', '') + if vc: + self.video_codec = vc.strip().lower() + + ve = meta.get('video_encode', '') + if ve: + self.video_encode = ve.strip().lower() + + vs = meta.get('source', '') + if vs: + self.video_source = vs.strip().lower() + + vt = meta.get('type', '') + if vt: + self.video_type = vt.strip().lower() + + def movie_type(self, meta): + # Possible values: "XviD", "DVDR", "x264", "x265", "MP4", "VCD" + if self.video_source == 'dvd': + return "DVDR" + + if self.video_codec == 'hevc': + return "x265" + else: + return "x264" + + def tv_type(self, meta): + # Possible values: "XviD", "HR-XviD", "x264-SD", "x264-HD", "x265-SD", "x265-HD", "Web-SD", "Web-HD", "DVDR", "MP4" + if self.video_source == 'dvd': + return "DVDR" + + if self.video_source == 'web': + if meta.get('sd'): + return "Web-SD" + else: + return "Web-HD" + + if self.video_codec == 'hevc': + if meta.get('sd'): + return "x265-SD" + else: + return "x265-HD" + else: + if meta.get('sd'): + return "x264-SD" + else: + return "x264-HD" + + def anime_type(self, meta): + # Possible values: "TVSeries", "TVSpecial", "Movie", "OVA", "ONA", "DVDSpecial" + if meta.get('tvmaze_episode_data', {}).get('season_number') == 0: + return "TVSpecial" + + if self.video_source == 'dvd': + return "DVDSpecial" + + category = meta['category'] + + if category == 'TV': + return "TVSeries" + + if category == 'MOVIE': + return "Movie" + + def movie_source(self, meta): + # Possible values: "DVD", "DVDSCR", "Workprint", "TeleCine", "TeleSync", "CAM", "BluRay", "HD-DVD", "HDTV", "R5", "WebRIP" + mapping = { + "dvd": "DVD", + "dvdscr": "DVDSCR", + "workprint": "Workprint", + "telecine": "TeleCine", + "telesync": "TeleSync", + "cam": "CAM", + "bluray": "BluRay", + "blu-ray": "BluRay", + "hd-dvd": "HD-DVD", + "hdtv": "HDTV", + "r5": "R5", + "web": "WebRIP", + "webrip": "WebRIP" + } + + src = (self.video_source or "").strip().lower() + return mapping.get(src, None) + + def tv_source(self, meta): + # Possible values: "HDTV", "DSR", "PDTV", "TV", "DVD", "DvdScr", "BluRay", "WebRIP" + mapping = { + "hdtv": "HDTV", + "dsr": "DSR", + "pdtv": "PDTV", + "tv": "TV", + "dvd": "DVD", + "dvdscr": "DvdScr", + "bluray": "BluRay", + "blu-ray": "BluRay", + "web": "WebRIP", + "webrip": "WebRIP" + } + + src = (self.video_source or "").strip().lower() + return mapping.get(src, None) + + def anime_source(self, meta): + # Possible values: "DVD", "BluRay", "Anime Series", "HDTV" + mapping = { + "hdtv": "HDTV", + "tv": "HDTV", + "dvd": "DVD", + "bluray": "BluRay", + "blu-ray": "BluRay", + "web": "Anime Series", + "webrip": "Anime Series" + } + + src = (self.video_source or "").strip().lower() + return mapping.get(src, None) + + def anime_v_dar(self, meta): + # Possible values: "16_9", "4_3" + if meta.get('is_disc') != "BDMV": + tracks = meta.get('mediainfo', {}).get('media', {}).get('track', []) + for track in tracks: + if track.get('@type') == "Video": + dar_str = track.get('DisplayAspectRatio') + if dar_str: + try: + dar = float(dar_str) + return "16_9" if dar > 1.34 else "4_3" + except (ValueError, TypeError): + return "16_9" + + return "16_9" + else: + return "16_9" + + def anime_v_codec(self, meta): + # Possible values: "x264", "h264", "XviD", "DivX", "WMV", "VC1" + if self.video_codec == 'vc-1': + return "VC1" + + if self.video_encode == 'h.264': + return "h264" + else: + return 'x264' + + def edit_name(self, meta): + is_scene = bool(meta.get('scene_name')) + torrent_name = meta['scene_name'] if is_scene else meta['name'] + + name = torrent_name.replace(':', '-') + name = unicodedata.normalize("NFKD", name) + name = name.encode("ascii", "ignore").decode("ascii") + name = re.sub(r'[\\/*?"<>|]', '', name) + + return name + + async def languages(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + lang_map = { + 'english': 'en', + 'japanese': 'jp', + 'korean': 'kr', + 'thai': 'th', + 'chinese': 'zh', + } + + anime_a_codec = [] + anime_a_ch = [] + anime_a_lang = [] + + anime_s_format = [] + anime_s_type = [] + anime_s_lang = [] + + audio_languages = meta.get('audio_languages', []) + if audio_languages: + audio_desc = meta.get('audio', '').lower() + found_codec = '0' + codec_options = { + 'aac': 'aac', 'ac3': 'ac3', 'dd': 'ac3', 'dolby digital': 'ac3', 'ogg': 'ogg', 'mp3': 'mp3', + 'dts-es': 'dtses', 'dtses': 'dtses', 'dts': 'dts', 'flac': 'flac', 'pcm': 'pcm', 'wma': 'wma' + } + for key, value in codec_options.items(): + if key in audio_desc: + found_codec = value + break + + channels_desc = meta.get('channels', '') + channel_map = { + '2.0': '2', + '5.1': '5_1', + '7.1': '7_1' + } + found_channel = channel_map.get(channels_desc, '0') + + for lang_str in audio_languages: + lang_code = lang_map.get(lang_str.lower(), '1') + + anime_a_codec.append(found_codec) + anime_a_ch.append(found_channel) + anime_a_lang.append(lang_code) + + subtitle_languages = meta.get('subtitle_languages', []) + if subtitle_languages: + subtitle_format = 'srt' + subtitle_type = 'sub' + + for lang_str in subtitle_languages: + lang_code = lang_map.get(lang_str.lower(), '1') + + anime_s_format.append(subtitle_format) + anime_s_type.append(subtitle_type) + anime_s_lang.append(lang_code) + + return { + 'anime_a_codec': anime_a_codec, + 'anime_a_ch': anime_a_ch, + 'anime_a_lang': anime_a_lang, + 'anime_s_format': anime_s_format, + 'anime_s_type': anime_s_type, + 'anime_s_lang': anime_s_lang, + } + + async def get_poster(self, meta): + poster_url = meta.get('poster') + + poster_file = None + if poster_url: + async with httpx.AsyncClient() as client: + response = await client.get(poster_url) + if response.status_code == 200: + poster_ext = os.path.splitext(poster_url)[1] or ".jpg" + poster_filename = f"{meta.get('name')}{poster_ext}" + poster_file = (poster_filename, response.content, "image/jpeg") + + return poster_file + + def get_nfo(self, meta): + nfo_dir = os.path.join(meta['base_dir'], "tmp", meta['uuid']) + nfo_files = glob.glob(os.path.join(nfo_dir, "*.nfo")) + + if nfo_files: + nfo_path = nfo_files[0] + + return { + 'nfo': ( + os.path.basename(nfo_path), + open(nfo_path, "rb"), + "application/octet-stream" + ) + } + return {} + + async def fetch_data(self, meta, disctype): + languages = await self.languages(meta) + self.file_information(meta) + + data = { + 'MAX_FILE_SIZE': 10000000, + 'type': self.get_type_id(meta), + 'tags': '', + 'descr': await self.generate_description(meta), + } + + if meta.get('anime'): + data.update({ + 'anime_type': self.anime_type(meta), + 'anime_source': self.anime_source(meta), + 'anime_container': 'mkv', + 'anime_v_res': meta.get('resolution'), + 'anime_v_dar': self.anime_v_dar(meta), + 'anime_v_codec': self.anime_v_codec(meta), + 'anime_a_codec[]': ['0'] + languages.get('anime_a_codec'), + 'anime_a_ch[]': ['0'] + languages.get('anime_a_ch'), + 'anime_a_lang[]': ['0'] + languages.get('anime_a_lang'), + 'anime_s_format[]': ['0'] + languages.get('anime_s_format'), + 'anime_s_type[]': ['0'] + languages.get('anime_s_type'), + 'anime_s_lang[]': ['0'] + languages.get('anime_s_lang'), + }) + + else: + if meta['category'] == 'MOVIE': + data.update({ + 'movie_type': self.movie_type(meta), + 'movie_source': self.movie_source(meta), + 'movie_imdb': f"https://www.imdb.com/title/{meta.get('imdb_info', {}).get('imdbID', '')}", + 'pack': 0, + }) + + if meta['category'] == 'TV': + data.update({ + 'tv_type': self.tv_type(meta), + 'tv_source': self.tv_source(meta), + 'tv_imdb': f"https://www.imdb.com/title/{meta.get('imdb_info', {}).get('imdbID', '')}", + 'pack': 1 if meta.get('tv_pack', 0) else 0, + }) + + return data + + async def upload(self, meta, disctype): + await self.validate_credentials(meta) + await self.edit_torrent(meta, self.tracker, self.source_flag) + data = await self.fetch_data(meta, disctype) + requests = await self.get_requests(meta) + status_message = '' + + if not meta.get('debug', False): + torrent_id = '' + upload_url = f"{self.base_url}/takeupload.php" + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = { + 'file': (f"{self.edit_name(meta)}.torrent", torrent_file, "application/x-bittorrent"), + } + files['poster'] = await self.get_poster(meta) + nfo = self.get_nfo(meta) + if nfo: + files['nfo'] = nfo['nfo'] + + response = await self.session.post(upload_url, data=data, files=files, timeout=30) + + if response.status_code == 302: + status_message = 'Torrent uploaded successfully.' + # Find the torrent id + match = re.search(r'details\.php\?id=(\d+)', response.text) + if match: + torrent_id = match.group(1) + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + if requests: + status_message += ' Your upload may fulfill existing requests, check prior console logs.' + + else: + status_message = 'The upload appears to have failed. It may have uploaded, go check.' + + response_save_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(response_save_path, "w", encoding="utf-8") as f: + f.write(response.text) + console.print(f"Upload failed, HTML response was saved to: {response_save_path}") + + meta['skipping'] = f"{self.tracker}" + return + + await asyncio.sleep(5) # the tracker takes a while to register the hash + await self.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce, self.torrent_url + torrent_id) + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading' + + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/FL.py b/src/trackers/FL.py index 06cd4bb0b..9c2fd0cf5 100644 --- a/src/trackers/FL.py +++ b/src/trackers/FL.py @@ -2,20 +2,18 @@ import asyncio import re import os -from pathlib import Path -import distutils.util -import json import glob import pickle from unidecode import unidecode -from urllib.parse import urlparse, quote +from urllib.parse import urlparse import cli_ui from bs4 import BeautifulSoup - +import httpx from src.trackers.COMMON import COMMON -from src.exceptions import * +from src.exceptions import * # noqa F403 from src.console import console + class FL(): def __init__(self, config): @@ -28,7 +26,6 @@ def __init__(self, config): self.uploader_name = config['TRACKERS'][self.tracker].get('uploader_name') self.signature = None self.banned_groups = [""] - async def get_category_id(self, meta): has_ro_audio, has_ro_sub = await self.get_ro_tracks(meta) @@ -51,7 +48,7 @@ async def get_category_id(self, meta): if has_ro_sub and meta.get('sd', 0) == 0 and meta['resolution'] != '2160p': # 19 = Movie + RO cat_id = 19 - + if meta['category'] == 'TV': # 21 = TV HD cat_id = 21 @@ -61,15 +58,15 @@ async def get_category_id(self, meta): elif meta.get('sd', 0) == 1: # 23 = TV SD cat_id = 23 - + if meta['is_disc'] == "DVD": # 2 = DVD cat_id = 2 if has_ro_sub: - # 3 = DVD + RO + # 3 = DVD + RO cat_id = 3 - if meta.get('anime', False) == True: + if meta.get('anime', False) is True: # 24 = Anime cat_id = 24 return cat_id @@ -85,8 +82,6 @@ async def edit_name(self, meta): fl_name = fl_name.replace(meta['title'], meta['imdb_info']['aka']) if meta['year'] != meta.get('imdb_info', {}).get('year', meta['year']) and str(meta['year']).strip() != '': fl_name = fl_name.replace(str(meta['year']), str(meta['imdb_info']['year'])) - if meta['category'] == "TV" and meta.get('tv_pack', 0) == 0 and meta.get('episode_title_storage', '').strip() != '' and meta['episode'].strip() != '': - fl_name = fl_name.replace(meta['episode'], f"{meta['episode']} {meta['episode_title_storage']}") if 'DD+' in meta.get('audio', '') and 'DDP' in meta['uuid']: fl_name = fl_name.replace('DD+', 'DDP') if 'Atmos' in meta.get('audio', '') and 'Atmos' not in meta['uuid']: @@ -98,28 +93,26 @@ async def edit_name(self, meta): fl_name = fl_name.replace('DTS7.1', 'DTS').replace('DTS5.1', 'DTS').replace('DTS2.0', 'DTS').replace('DTS1.0', 'DTS') fl_name = fl_name.replace('Dubbed', '').replace('Dual-Audio', '') fl_name = ' '.join(fl_name.split()) - fl_name = re.sub("[^0-9a-zA-ZÀ-ÿ. &+'\-\[\]]+", "", fl_name) + fl_name = re.sub(r"[^0-9a-zA-ZÀ-ÿ. &+'\-\[\]]+", "", fl_name) fl_name = fl_name.replace(' ', '.').replace('..', '.') - return fl_name + return fl_name - - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### + def _is_true(value): + return str(value).strip().lower() in {"true", "1", "yes"} - async def upload(self, meta): + async def upload(self, meta, disctype): common = COMMON(config=self.config) await common.edit_torrent(meta, self.tracker, self.source_flag) await self.edit_desc(meta) fl_name = await self.edit_name(meta) cat_id = await self.get_category_id(meta) has_ro_audio, has_ro_sub = await self.get_ro_tracks(meta) - + # Confirm the correct naming order for FL cli_ui.info(f"Filelist name: {fl_name}") - if meta.get('unattended', False) == False: + if meta.get('unattended', False) is False: fl_confirm = cli_ui.ask_yes_no("Correct?", default=False) - if fl_confirm != True: + if fl_confirm is not True: fl_name_manually = cli_ui.ask_string("Please enter a proper name", default="") if fl_name_manually == "": console.print('No proper name given') @@ -130,38 +123,38 @@ async def upload(self, meta): # Torrent File Naming # Note: Don't Edit .torrent filename after creation, SubsPlease anime releases (because of their weird naming) are an exception - if meta.get('anime', True) == True and meta.get('tag', '') == '-SubsPlease': + if meta.get('anime', True) is True and meta.get('tag', '') == '-SubsPlease': torrentFileName = fl_name else: - if meta.get('isdir', False) == False: + if meta.get('isdir', False) is False: torrentFileName = meta.get('uuid') torrentFileName = os.path.splitext(torrentFileName)[0] else: torrentFileName = meta.get('uuid') # Download new .torrent from site - fl_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', newline='').read() - torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent" - if meta['bdinfo'] != None: + fl_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', newline='', encoding='utf-8').read() + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + if meta['bdinfo'] is not None: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() else: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8').read() with open(torrent_path, 'rb') as torrentFile: torrentFileName = unidecode(torrentFileName) files = { - 'file' : (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent") + 'file': (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent") } data = { - 'name' : fl_name, - 'type' : cat_id, - 'descr' : fl_desc.strip(), - 'nfo' : mi_dump + 'name': fl_name, + 'type': cat_id, + 'descr': fl_desc.strip(), + 'nfo': mi_dump } - if int(meta.get('imdb_id', '').replace('tt', '')) != 0: - data['imdbid'] = meta.get('imdb_id', '').replace('tt', '') + if int(meta.get('imdb_id')) != 0: + data['imdbid'] = meta.get('imdb') data['description'] = meta['imdb_info'].get('genres', '') - if self.uploader_name not in ("", None) and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: + if self.uploader_name not in ("", None) and not self._is_true(self.config['TRACKERS'][self.tracker].get('anon', "False")): data['epenis'] = self.uploader_name if has_ro_audio: data['materialro'] = 'on' @@ -177,6 +170,7 @@ async def upload(self, meta): if meta['debug']: console.print(url) console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." else: with requests.Session() as session: cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/FL.pkl") @@ -184,63 +178,74 @@ async def upload(self, meta): session.cookies.update(pickle.load(cf)) up = session.post(url=url, data=data, files=files) torrentFile.close() - + # Match url to verify successful upload match = re.match(r".*?filelist\.io/details\.php\?id=(\d+)&uploaded=(\d+)", up.url) if match: + meta['tracker_status'][self.tracker]['status_message'] = match.group(0) id = re.search(r"(id=)(\d+)", urlparse(up.url).query).group(2) await self.download_new_torrent(session, id, torrent_path) else: console.print(data) console.print("\n\n") console.print(up.text) - raise UploadException(f"Upload to FL Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') + raise UploadException(f"Upload to FL Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') # noqa F405 return - - async def search_existing(self, meta): + async def search_existing(self, meta, disctype): dupes = [] - with requests.Session() as session: - cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/FL.pkl") - with open(cookiefile, 'rb') as cf: - session.cookies.update(pickle.load(cf)) - - search_url = f"https://filelist.io/browse.php" - if int(meta['imdb_id'].replace('tt', '')) != 0: - params = { - 'search' : meta['imdb_id'], - 'cat' : await self.get_category_id(meta), - 'searchin' : '3' - } - else: - params = { - 'search' : meta['title'], - 'cat' : await self.get_category_id(meta), - 'searchin' : '0' - } - - r = session.get(search_url, params=params) - await asyncio.sleep(0.5) - soup = BeautifulSoup(r.text, 'html.parser') - find = soup.find_all('a', href=True) - for each in find: - if each['href'].startswith('details.php?id=') and "&" not in each['href']: - dupes.append(each['title']) + cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/FL.pkl") - return dupes + with open(cookiefile, 'rb') as cf: + cookies = pickle.load(cf) - + search_url = "https://filelist.io/browse.php" + if int(meta['imdb_id']) != 0: + params = { + 'search': meta['imdb'], + 'cat': await self.get_category_id(meta), + 'searchin': '3' + } + else: + params = { + 'search': meta['title'], + 'cat': await self.get_category_id(meta), + 'searchin': '0' + } + + try: + async with httpx.AsyncClient(cookies=cookies, timeout=10.0) as client: + response = await client.get(search_url, params=params) + if response.status_code == 200: + soup = BeautifulSoup(response.text, 'html.parser') + find = soup.find_all('a', href=True) + for each in find: + if each['href'].startswith('details.php?id=') and "&" not in each['href']: + dupes.append(each['title']) + else: + console.print(f"[bold red]Failed to search torrents. HTTP Status: {response.status_code}") + await asyncio.sleep(0.5) + + except httpx.TimeoutException: + console.print("[bold red]Request timed out while searching for existing torrents.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + await asyncio.sleep(0.5) + + return dupes async def validate_credentials(self, meta): cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/FL.pkl") if not os.path.exists(cookiefile): await self.login(cookiefile) vcookie = await self.validate_cookies(meta, cookiefile) - if vcookie != True: + if vcookie is not True: console.print('[red]Failed to validate cookies. Please confirm that the site is up and your passkey is valid.') recreate = cli_ui.ask_yes_no("Log in again and create new session?") - if recreate == True: + if recreate is True: if os.path.exists(cookiefile): os.remove(cookiefile) await self.login(cookiefile) @@ -249,8 +254,7 @@ async def validate_credentials(self, meta): else: return False return True - - + async def validate_cookies(self, meta, cookiefile): url = "https://filelist.io/index.php" if os.path.exists(cookiefile): @@ -259,8 +263,6 @@ async def validate_cookies(self, meta, cookiefile): session.cookies.update(pickle.load(cf)) resp = session.get(url=url) if meta['debug']: - console.print('[cyan]Cookies:') - console.print(session.cookies.get_dict()) console.print(resp.url) if resp.text.find("Logout") != -1: return True @@ -268,18 +270,18 @@ async def validate_cookies(self, meta, cookiefile): return False else: return False - + async def login(self, cookiefile): with requests.Session() as session: r = session.get("https://filelist.io/login.php") await asyncio.sleep(0.5) soup = BeautifulSoup(r.text, 'html.parser') - validator = soup.find('input', {'name' : 'validator'}).get('value') + validator = soup.find('input', {'name': 'validator'}).get('value') data = { - 'validator' : validator, - 'username' : self.username, - 'password' : self.password, - 'unlock' : '1', + 'validator': validator, + 'username': self.username, + 'password': self.password, + 'unlock': '1', } response = session.post('https://filelist.io/takelogin.php', data=data) await asyncio.sleep(0.5) @@ -306,26 +308,24 @@ async def download_new_torrent(self, session, id, torrent_path): console.print(r.text) return - - async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', newline='') as descfile: + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8').read() + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', newline='', encoding='utf-8') as descfile: from src.bbcode import BBCODE bbcode = BBCODE() - + desc = base desc = bbcode.remove_spoiler(desc) desc = bbcode.convert_code_to_quote(desc) desc = bbcode.convert_comparison_to_centered(desc, 900) desc = desc.replace('[img]', '[img]').replace('[/img]', '[/img]') - desc = re.sub("(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) + desc = re.sub(r"(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) if meta['is_disc'] != 'BDMV': url = "https://up.img4k.net/api/description" data = { - 'mediainfo' : open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r').read(), + 'mediainfo': open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r').read(), } - if int(meta['imdb_id'].replace('tt', '')) != 0: + if int(meta['imdb_id']) != 0: data['imdbURL'] = f"tt{meta['imdb_id']}" screen_glob = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"{meta['filename']}-*.png") files = [] @@ -336,10 +336,10 @@ async def edit_desc(self, meta): else: # BD Description Generator final_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_EXT.txt", 'r', encoding='utf-8').read() - if final_desc.strip() != "": # Use BD_SUMMARY_EXT and bbcode format it + if final_desc.strip() != "": # Use BD_SUMMARY_EXT and bbcode format it final_desc = final_desc.replace('[/pre][/quote]', f'[/pre][/quote]\n\n{desc}\n', 1) final_desc = final_desc.replace('DISC INFO:', '[pre][quote=BD_Info][b][color=#FF0000]DISC INFO:[/color][/b]').replace('PLAYLIST REPORT:', '[b][color=#FF0000]PLAYLIST REPORT:[/color][/b]').replace('VIDEO:', '[b][color=#FF0000]VIDEO:[/color][/b]').replace('AUDIO:', '[b][color=#FF0000]AUDIO:[/color][/b]').replace('SUBTITLES:', '[b][color=#FF0000]SUBTITLES:[/color][/b]') - final_desc += "[/pre][/quote]\n" # Closed bbcode tags + final_desc += "[/pre][/quote]\n" # Closed bbcode tags # Upload screens and append to the end of the description url = "https://up.img4k.net/api/description" screen_glob = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"{meta['filename']}-*.png") @@ -350,11 +350,10 @@ async def edit_desc(self, meta): final_desc += response.text.replace('\r\n', '\n') descfile.write(final_desc) - if self.signature != None: + if self.signature is not None: descfile.write(self.signature) descfile.close() - async def get_ro_tracks(self, meta): has_ro_audio = has_ro_sub = False if meta.get('is_disc', '') != 'BDMV': diff --git a/src/trackers/FNP.py b/src/trackers/FNP.py new file mode 100644 index 000000000..b3541d2a5 --- /dev/null +++ b/src/trackers/FNP.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class FNP(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='FNP') + self.config = config + self.common = COMMON(config) + self.tracker = 'FNP' + self.source_flag = 'FnP' + self.base_url = 'https://fearnopeer.com' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + "4K4U", "BiTOR", "d3g", "FGT", "FRDS", "FTUApps", "GalaxyRG", "LAMA", + "MeGusta", "NeoNoir", "PSA", "RARBG", "YAWNiX", "YTS", "YIFY", "x0r" + ] + pass + + async def get_resolution_id(self, meta): + resolution_id = { + '4320p': '1', + '2160p': '2', + '1080p': '3', + '1080i': '11', + '720p': '5', + '576p': '6', + '576i': '15', + '480p': '8', + '480i': '14' + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} + + async def get_additional_data(self, meta): + data = { + 'modq': await self.get_flag(meta, 'modq'), + } + + return data diff --git a/src/trackers/FRIKI.py b/src/trackers/FRIKI.py new file mode 100644 index 000000000..744e11d96 --- /dev/null +++ b/src/trackers/FRIKI.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# import discord +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class FRIKI(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='FRIKI') + self.config = config + self.common = COMMON(config) + self.tracker = 'FRIKI' + self.source_flag = 'frikibar.com' + self.base_url = 'https://frikibar.com' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [""] + pass diff --git a/src/trackers/GPW.py b/src/trackers/GPW.py new file mode 100644 index 000000000..fdfdab554 --- /dev/null +++ b/src/trackers/GPW.py @@ -0,0 +1,814 @@ +# -*- coding: utf-8 -*- +import asyncio +import httpx +import json +import os +import re +import unicodedata +from bs4 import BeautifulSoup +from src.console import console +from src.languages import process_desc_language +from src.rehostimages import check_hosts +from src.tmdb import get_tmdb_localized_data +from src.trackers.COMMON import COMMON +from typing import Dict + + +class GPW(): + def __init__(self, config): + self.config = config + self.common = COMMON(config) + self.tracker = 'GPW' + self.source_flag = 'GreatPosterWall' + self.base_url = 'https://greatposterwall.com' + self.torrent_url = f'{self.base_url}/torrents.php?torrentid=' + self.announce = self.config['TRACKERS'][self.tracker]['announce_url'] + self.api_key = self.config['TRACKERS'][self.tracker]['api_key'] + self.auth_token = None + self.signature = "[center][url=https://github.com/Audionut/Upload-Assistant]Created by Upload Assistant[/url][/center]" + self.banned_groups = [ + 'ALT', 'aXXo', 'BATWEB', 'BlackTV', 'BitsTV', 'BMDRu', 'BRrip', 'CM8', 'CrEwSaDe', 'CTFOH', 'CTRLHD', + 'DDHDTV', 'DNL', 'DreamHD', 'ENTHD', 'FaNGDiNG0', 'FGT', 'FRDS', 'HD2DVD', 'HDTime', + 'HDT', 'Huawei', 'GPTHD', 'ION10', 'iPlanet', 'KiNGDOM', 'Leffe', 'Mp4Ba', 'mHD', 'MiniHD', 'mSD', 'MOMOWEB', + 'nHD', 'nikt0', 'NSBC', 'nSD', 'NhaNc3', 'NukeHD', 'OFT', 'PRODJi', 'RARBG', 'RDN', 'SANTi', 'SeeHD', 'SeeWEB', + 'SM737', 'SonyHD', 'STUTTERSHIT', 'TAGWEB', 'ViSION', 'VXT', 'WAF', 'x0r', 'Xiaomi', 'YIFY', + ['EVO', 'web-dl Only'] + ] + self.approved_image_hosts = ['kshare', 'pixhost', 'ptpimg', 'pterclub', 'ilikeshots', 'imgbox'] + self.url_host_mapping = { + 'kshare.club': 'kshare', + 'pixhost.to': 'pixhost', + 'imgbox.com': 'imgbox', + 'ptpimg.me': 'ptpimg', + 'img.pterclub.com': 'pterclub', + 'yes.ilikeshots.club': 'ilikeshots', + } + + async def load_cookies(self, meta): + cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + if not os.path.exists(cookie_file): + return False + + return await self.common.parseCookieFile(cookie_file) + + def load_localized_data(self, meta): + localized_data_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/tmdb_localized_data.json" + + if os.path.isfile(localized_data_file): + with open(localized_data_file, "r", encoding="utf-8") as f: + self.tmdb_data = json.load(f) + else: + self.tmdb_data = {} + + async def ch_tmdb_data(self, meta): + brazil_data_in_meta = self.tmdb_data.get('zh-cn', {}).get('main') + if brazil_data_in_meta: + return brazil_data_in_meta + + data = await get_tmdb_localized_data(meta, data_type='main', language='zh-cn', append_to_response='credits') + self.load_localized_data(meta) + + return data + + async def get_container(self, meta): + container = meta.get('container', '') + if container == 'm2ts': + return container + elif container == 'vob': + return 'VOB IFO' + elif container in ['avi', 'mpg', 'mp4', 'mkv']: + return container.upper() + + return 'Other' + + async def get_subtitle(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + found_language_strings = meta.get('subtitle_languages', []) + + if found_language_strings: + return [lang.lower() for lang in found_language_strings] + else: + return [] + + async def get_ch_dubs(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + found_language_strings = meta.get('audio_languages', []) + + chinese_languages = {'mandarin', 'chinese', 'zh', 'zh-cn', 'zh-hans', 'zh-hant', 'putonghua', '国语', '普通话'} + for lang in found_language_strings: + if lang.strip().lower() in chinese_languages: + return True + return False + + async def get_codec(self, meta): + video_encode = meta.get('video_encode', '').strip().lower() + codec_final = meta.get('video_codec', '').strip().lower() + + codec_map = { + 'divx': 'DivX', + 'xvid': 'XviD', + 'x264': 'x264', + 'h.264': 'H.264', + 'x265': 'x265', + 'h.265': 'H.265', + 'hevc': 'H.265', + } + + for key, value in codec_map.items(): + if key in video_encode or key in codec_final: + return value + + return 'Other' + + async def get_audio_codec(self, meta): + priority_order = [ + 'DTS-X', 'E-AC-3 JOC', 'TrueHD', 'DTS-HD', 'PCM', 'FLAC', 'DTS-ES', + 'DTS', 'E-AC-3', 'AC3', 'AAC', 'Opus', 'Vorbis', 'MP3', 'MP2' + ] + + codec_map = { + 'DTS-X': ['DTS:X'], + 'E-AC-3 JOC': ['DD+ 5.1 Atmos', 'DD+ 7.1 Atmos'], + 'TrueHD': ['TrueHD'], + 'DTS-HD': ['DTS-HD'], + 'PCM': ['LPCM'], + 'FLAC': ['FLAC'], + 'DTS-ES': ['DTS-ES'], + 'DTS': ['DTS'], + 'E-AC-3': ['DD+'], + 'AC3': ['DD'], + 'AAC': ['AAC'], + 'Opus': ['Opus'], + 'Vorbis': ['VORBIS'], + 'MP2': ['MP2'], + 'MP3': ['MP3'] + } + + audio_description = meta.get('audio') + + if not audio_description or not isinstance(audio_description, str): + return 'Outro' + + for codec_name in priority_order: + search_terms = codec_map.get(codec_name, []) + + for term in search_terms: + if term in audio_description: + return codec_name + + return 'Outro' + + async def get_title(self, meta): + tmdb_data = await self.ch_tmdb_data(meta) + + title = tmdb_data.get('name') or tmdb_data.get('title') or '' + + return title if title and title != meta.get('title') else '' + + async def get_release_desc(self, meta): + description = [] + + # Disc + region = meta.get('region', '') + distributor = meta.get('distributor', '') + if region or distributor: + disc_info = '' + if region: + disc_info += f'[b]Disc Region:[/b] {region}\n' + if distributor: + disc_info += f'[b]Disc Distributor:[/b] {distributor.title()}' + description.append(disc_info) + + base_desc = '' + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + if os.path.exists(base_desc_path) and os.path.getsize(base_desc_path) > 0: + with open(base_desc_path, 'r', encoding='utf-8') as file: + base_desc = file.read().strip() + + if base_desc: + print('\nFound existing description:\n') + print(base_desc) + user_input = input('Do you want to use this description? (y/n): ') + + if user_input.lower() == 'y': + description.append(base_desc) + print('Using existing description.') + else: + print('Ignoring existing description.') + + # Screenshots + # Rule: 2.2.1. Screenshots: They have to be saved at kshare.club, pixhost.to, ptpimg.me, img.pterclub.com, yes.ilikeshots.club, imgbox.com, s3.pterclub.com + await check_hosts(meta, self.tracker, url_host_mapping=self.url_host_mapping, img_host_index=1, approved_image_hosts=self.approved_image_hosts) + + if f'{self.tracker}_images_key' in meta: + images = meta[f'{self.tracker}_images_key'] + else: + images = meta['image_list'] + if images: + screenshots_block = '[center]\n' + for i, image in enumerate(images, start=1): + screenshots_block += f"[img]{image['raw_url']}[/img] " + if i % 2 == 0: + screenshots_block += '\n' + screenshots_block += '\n[/center]' + description.append(screenshots_block) + + custom_description_header = self.config['DEFAULT'].get('custom_description_header', '') + if custom_description_header: + description.append(custom_description_header + '\n') + + if self.signature: + description.append(self.signature) + + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + with open(final_desc_path, 'w', encoding='utf-8') as descfile: + from src.bbcode import BBCODE + bbcode = BBCODE() + desc = '\n\n'.join(filter(None, description)) + desc = desc.replace('[sup]', '').replace('[/sup]', '') + desc = desc.replace('[sub]', '').replace('[/sub]', '') + desc = bbcode.remove_spoiler(desc) + desc = bbcode.convert_code_to_quote(desc) + desc = re.sub(r'\[(right|center|left)\]', lambda m: f"[align={m.group(1)}]", desc) + desc = re.sub(r'\[/(right|center|left)\]', "[/align]", desc) + final_description = re.sub(r'\n{3,}', '\n\n', desc) + descfile.write(final_description) + + return final_description + + async def get_trailer(self, meta): + tmdb_data = await self.ch_tmdb_data(meta) + video_results = tmdb_data.get('videos', {}).get('results', []) + + youtube = '' + + if video_results: + youtube = video_results[-1].get('key', '') + + if not youtube: + meta_trailer = meta.get('youtube', '') + if meta_trailer: + youtube = meta_trailer.replace('https://www.youtube.com/watch?v=', '').replace('/', '') + + return youtube + + async def get_tags(self, meta): + tags = '' + + genres = meta.get('genres', '') + if genres and isinstance(genres, str): + genre_names = [g.strip() for g in genres.split(',') if g.strip()] + if genre_names: + tags = ', '.join( + unicodedata.normalize('NFKD', name) + .encode('ASCII', 'ignore') + .decode('utf-8') + .replace(' ', '.') + .lower() + for name in genre_names + ) + + if not tags: + tags = await asyncio.to_thread(input, f'Enter the genres (in {self.tracker} format): ') + + return tags + + async def search_existing(self, meta, disctype): + if meta['category'] != 'MOVIE': + console.print(f'{self.tracker}: Only feature films, short films, and live performances are permitted on {self.tracker}') + meta['skipping'] = f'{self.tracker}' + return + + group_id = await self.get_groupid(meta) + if not group_id: + return [] + + imdb = meta.get("imdb_info", {}).get("imdbID") + + cookies = await self.load_cookies(meta) + if not cookies: + search_url = f'{self.base_url}/api.php?api_key={self.api_key}&action=torrent&imdbID={imdb}' + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get(search_url) + response.raise_for_status() + data = response.json() + + if data.get('status') == 200 and 'response' in data: + results = [] + for item in data['response']: + name = item.get('Name', '') + year = item.get('Year', '') + resolution = item.get('Resolution', '') + source = item.get('Source', '') + processing = item.get('Processing', '') + remaster = item.get('RemasterTitle', '') + codec = item.get('Codec', '') + + formatted = f'{name} {year} {resolution} {source} {processing} {remaster} {codec}'.strip() + formatted = re.sub(r'\s{2,}', ' ', formatted) + results.append(formatted) + return results + else: + return [] + except Exception as e: + print(f'An unexpected error occurred while processing the search: {e}') + return [] + + else: + search_url = f'{self.base_url}/torrents.php?groupname={imdb.upper()}' # using TT in imdb returns the search page instead of redirecting to the group page + found_items = [] + + try: + async with httpx.AsyncClient(cookies=cookies, timeout=30, headers={'User-Agent': 'Upload Assistant/2.3'}) as client: + response = await client.get(search_url) + response.raise_for_status() + soup = BeautifulSoup(response.text, 'html.parser') + + torrent_table = soup.find('table', id='torrent_table') + if not torrent_table: + return [] + + for torrent_row in torrent_table.find_all('tr', class_='TableTorrent-rowTitle'): + title_link = torrent_row.find('a', href=re.compile(r'torrentid=\d+')) + if not title_link or not title_link.get('data-tooltip'): + continue + + name = title_link['data-tooltip'] + + size_cell = torrent_row.find('td', class_='TableTorrent-cellStatSize') + size = size_cell.get_text(strip=True) if size_cell else None + + match = re.search(r'torrentid=(\d+)', title_link['href']) + torrent_link = f'{self.torrent_url}{match.group(1)}' if match else None + + dupe_entry = { + 'name': name, + 'size': size, + 'link': torrent_link + } + + found_items.append(dupe_entry) + + if found_items: + await self.get_slots(meta, client, group_id) + + return found_items + + except httpx.HTTPError as e: + print(f'An HTTP error occurred: {e}') + return [] + except Exception as e: + print(f'An unexpected error occurred while processing the search: {e}') + return [] + + async def get_slots(self, meta, client, group_id): + url = f'{self.base_url}/torrents.php?id={group_id}' + + try: + response = await client.get(url) + response.raise_for_status() + except httpx.HTTPStatusError as e: + print(f'Error on request: {e.response.status_code} - {e.response.reason_phrase}') + return + + soup = BeautifulSoup(response.text, 'html.parser') + + empty_slot_rows = soup.find_all('tr', class_='TableTorrent-rowEmptySlotNote') + + for row in empty_slot_rows: + edition_id = row.get('edition-id') + resolution = '' + + if edition_id == '1': + resolution = 'SD' + elif edition_id == '3': + resolution = '2160p' + + if not resolution: + slot_type_tag = row.find('td', class_='TableTorrent-cellEmptySlotNote').find('i') + if slot_type_tag: + resolution = slot_type_tag.get_text(strip=True).replace('empty slots:', '').strip() + + slot_names = [] + + i_tags = row.find_all('i') + for tag in i_tags: + text = tag.get_text(strip=True) + if 'empty slots:' not in text: + slot_names.append(text) + + span_tags = row.find_all('span', class_='tooltipstered') + for tag in span_tags: + slot_names.append(tag.find('i').get_text(strip=True)) + + final_slots_list = sorted(list(set(slot_names))) + formatted_slots = [f'- {slot}' for slot in final_slots_list] + final_slots = '\n'.join(formatted_slots) + + if final_slots: + final_slots = final_slots.replace('Slot', '').replace('Empty slots:', '').strip() + if resolution == meta.get('resolution'): + console.print(f'\n[green]Available Slots for[/green] {resolution}:') + console.print(f'{final_slots}\n') + + async def get_media_info(self, meta): + info_file_path = '' + if meta.get('is_disc') == 'BDMV': + info_file_path = f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/BD_SUMMARY_00.txt" + else: + info_file_path = f"{meta.get('base_dir')}/tmp/{meta.get('uuid')}/MEDIAINFO_CLEANPATH.txt" + + if os.path.exists(info_file_path): + try: + with open(info_file_path, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + console.print(f'[bold red]Error reading info file at {info_file_path}: {e}[/bold red]') + return '' + else: + console.print(f'[bold red]Info file not found: {info_file_path}[/bold red]') + return '' + + async def get_edition(self, meta): + edition_str = meta.get('edition', '').lower() + if not edition_str: + return '' + + edition_map = { + "director's cut": "Director's Cut", + 'theatrical': 'Theatrical Cut', + 'extended': 'Extended', + 'uncut': 'Uncut', + 'unrated': 'Unrated', + 'imax': 'IMAX', + 'noir': 'Noir', + 'remastered': 'Remastered', + } + + for keyword, label in edition_map.items(): + if keyword in edition_str: + return label + + return '' + + async def get_processing_other(self, meta): + if meta.get('type') == 'DISC': + is_disc_type = meta.get('is_disc') + + if is_disc_type == 'BDMV': + disctype = meta.get('disctype') + if disctype in ['BD100', 'BD66', 'BD50', 'BD25']: + return disctype + + try: + size_in_gb = meta['bdinfo']['size'] + except (KeyError, IndexError, TypeError): + size_in_gb = 0 + + if size_in_gb > 66: + return 'BD100' + elif size_in_gb > 50: + return 'BD66' + elif size_in_gb > 25: + return 'BD50' + else: + return 'BD25' + + elif is_disc_type == 'DVD': + dvd_size = meta.get('dvd_size') + if dvd_size in ['DVD9', 'DVD5']: + return dvd_size + return 'DVD9' + + async def get_screens(self, meta): + screenshot_urls = [ + image.get('raw_url') + for image in meta.get('image_list', []) + if image.get('raw_url') + ] + + return screenshot_urls + + async def get_credits(self, meta): + director = (meta.get('imdb_info', {}).get('directors') or []) + (meta.get('tmdb_directors') or []) + if director: + unique_names = list(dict.fromkeys(director))[:5] + return ', '.join(unique_names) + else: + return 'N/A' + + async def get_remaster_title(self, meta): + found_tags = [] + + def add_tag(tag_id): + if tag_id and tag_id not in found_tags: + found_tags.append(tag_id) + + # Collections + distributor = meta.get('distributor', '').upper() + if distributor in ('WARNER ARCHIVE', 'WARNER ARCHIVE COLLECTION', 'WAC'): + add_tag('warner_archive_collection') + elif distributor in ('CRITERION', 'CRITERION COLLECTION', 'CC'): + add_tag('the_criterion_collection') + elif distributor in ('MASTERS OF CINEMA', 'MOC'): + add_tag('masters_of_cinema') + + # Editions + edition = meta.get('edition', '').lower() + if "director's cut" in edition: + add_tag('director_s_cut') + elif 'extended' in edition: + add_tag('extended_edition') + elif 'theatrical' in edition: + add_tag('theatrical_cut') + elif 'rifftrax' in edition: + add_tag('rifftrax') + elif 'uncut' in edition: + add_tag('uncut') + elif 'unrated' in edition: + add_tag('unrated') + + # Audio + if meta.get('dual_audio', False): + add_tag('dual_audio') + + if meta.get('extras'): + add_tag('extras') + + # Commentary + has_commentary = meta.get('has_commentary', False) or meta.get('manual_commentary', False) + + # Ensure 'with_commentary' is last if it exists + if has_commentary: + add_tag('with_commentary') + if 'with_commentary' in found_tags: + found_tags.remove('with_commentary') + found_tags.append('with_commentary') + + if not found_tags: + return '', '' + + remaster_title_show = ' / '.join(found_tags) + + return remaster_title_show + + async def get_groupid(self, meta): + search_url = f"{self.base_url}/api.php?api_key={self.api_key}&action=torrent&req=group&imdbID={meta.get('imdb_info', {}).get('imdbID')}" + + try: + async with httpx.AsyncClient(timeout=30) as client: + response = await client.get(search_url) + response.raise_for_status() + + except httpx.RequestError as e: + console.print(f'[bold red]Network error fetching groupid: {e}[/bold red]') + return None + except httpx.HTTPStatusError as e: + console.print(f'[bold red]HTTP error when fetching groupid: Status {e.response.status_code}[/bold red]') + return None + + try: + data = response.json() + except Exception as e: + console.print(f'[bold red]Error decoding JSON from groupid response: {e}[/bold red]') + return None + + if data.get('status') == 200 and 'response' in data and 'ID' in data['response']: + return str(data['response']['ID']) + return None + + async def get_additional_data(self, meta): + tmdb_data = await self.ch_tmdb_data(meta) + poster_url = '' + while True: + poster_url = input(f"{self.tracker}: Enter the poster image URL (must be from one of {', '.join(self.approved_image_hosts)}): \n").strip() + if any(host in poster_url for host in self.approved_image_hosts): + break + else: + console.print('[red]Invalid host. Please use a URL from the allowed hosts.[/red]') + + data = { + 'desc': tmdb_data.get('overview', ''), + 'image': poster_url, + 'imdb': meta.get('imdb_info', {}).get('imdbID'), + 'maindesc': meta.get('overview', ''), + 'name': meta.get('title'), + 'releasetype': self._get_movie_type(meta), + 'subname': await self.get_title(meta), + 'tags': await self.get_tags(meta), + 'year': meta.get('year'), + } + data.update(self._get_artist_data(meta)) + + return data + + def _get_artist_data(self, meta) -> Dict[str, str]: + directors = meta.get('imdb_info', {}).get('directors', []) + directors_id = meta.get('imdb_info', {}).get('directors_id', []) + + if directors and directors_id: + imdb_id = directors_id[0] + english_name = directors[0] + chinese_name = '' + else: + console.print(f'{self.tracker}: This movie is not registered in the {self.tracker} database, please enter the details of 1 director') + imdb_id = input('Enter Director IMDb ID (e.g., nm0000138): ') + english_name = input('Enter Director English name: ') + chinese_name = input('Enter Director Chinese name (optional, press Enter to skip): ') + + post_data = { + 'artist_ids[]': imdb_id, + 'artists[]': english_name, + 'artists_sub[]': chinese_name, + 'importance[]': '1' + } + + return post_data + + def _get_movie_type(self, meta): + movie_type = '' + imdb_info = meta.get('imdb_info', {}) + if imdb_info: + imdbType = imdb_info.get('type', 'movie').lower() + if imdbType in ("movie", "tv movie", 'tvmovie'): + if int(imdb_info.get('runtime', '60')) >= 45 or int(imdb_info.get('runtime', '60')) == 0: + movie_type = '1' # Feature Film + else: + movie_type = '2' # Short Film + + return movie_type + + async def get_source(self, meta): + source_type = meta.get('type', '').lower() + + if source_type == 'disc': + is_disc = meta.get('is_disc', '').upper() + if is_disc == 'BDMV': + return 'Blu-ray' + elif is_disc in ('HDDVD', 'DVD'): + return 'DVD' + else: + return 'Other' + + keyword_map = { + 'webdl': 'WEB', + 'webrip': 'WEB', + 'web': 'WEB', + 'remux': 'Blu-ray', + 'encode': 'Blu-ray', + 'bdrip': 'Blu-ray', + 'brrip': 'Blu-ray', + 'hdtv': 'HDTV', + 'sdtv': 'TV', + 'dvdrip': 'DVD', + 'hd-dvd': 'HD-DVD', + 'dvdscr': 'DVD', + 'pdtv': 'TV', + 'uhdtv': 'HDTV', + 'vhs': 'VHS', + 'tvrip': 'TVRip', + } + + return keyword_map.get(source_type, 'Other') + + async def get_processing(self, meta): + type_map = { + 'ENCODE': 'Encode', + 'REMUX': 'Remux', + 'DIY': 'DIY', + 'UNTOUCHED': 'Untouched' + } + release_type = meta.get('type', '').strip().upper() + return type_map.get(release_type, 'Untouched') + + def get_media_flags(self, meta): + audio = meta.get('audio', '').lower() + hdr = meta.get('hdr', '') + bit_depth = meta.get('bit_depth', '') + channels = meta.get('channels', '') + + flags = {} + + # audio flags + if 'atmos' in audio: + flags['dolby_atmos'] = 'on' + + if 'dts:x' in audio: + flags['dts_x'] = 'on' + + if channels == '5.1': + flags['audio_51'] = 'on' + + if channels == '7.1': + flags['audio_71'] = 'on' + + # video flags + if not hdr.strip() and bit_depth == '10': + flags['10_bit'] = 'on' + + if 'DV' in hdr: + flags['dolby_vision'] = 'on' + + if 'HDR' in hdr: + flags['hdr10plus' if 'HDR10+' in hdr else 'hdr10'] = 'on' + + return flags + + async def fetch_data(self, meta, disctype): + self.load_localized_data(meta) + remaster_title = await self.get_remaster_title(meta) + codec = await self.get_codec(meta) + container = await self.get_container(meta) + groupid = await self.get_groupid(meta) + + data = {} + + if not groupid: + console.print(f'{self.tracker}: This movie is not registered in the database, please enter additional information.') + data.update(await self.get_additional_data(meta)) + + data.update({ + 'codec_other': meta.get('video_codec', '') if codec == 'Other' else '', + 'codec': codec, + 'container_other': meta.get('container', '') if container == 'Other' else '', + 'container': container, + 'groupid': groupid if groupid else '', + 'mediainfo[]': await self.get_media_info(meta), + 'movie_edition_information': 'on' if remaster_title else '', + 'processing_other': await self.get_processing_other(meta) if meta.get('type') == 'DISC' else '', + 'processing': await self.get_processing(meta), + 'release_desc': await self.get_release_desc(meta), + 'remaster_custom_title': '', + 'remaster_title': remaster_title, + 'remaster_year': '', + 'resolution_height': '', + 'resolution_width': '', + 'resolution': meta.get('resolution'), + 'source_other': '', + 'source': await self.get_source(meta), + 'submit': 'true', + 'subtitle_type': ('2' if meta.get('hardcoded-subs', False) else '1' if meta.get('subtitle_languages', []) else '3'), + 'subtitles[]': await self.get_subtitle(meta), + }) + + if await self.get_ch_dubs(meta): + data.update({ + 'chinese_dubbed': 'on' + }) + + if meta.get('sfx_subtitles', False): + data.update({ + 'special_effects_subtitles': 'on' + }) + + if meta.get('scene', False): + data.update({ + 'scene': 'on' + }) + + if meta.get('personalrelease', False): + data.update({ + 'self_rip': 'on' + }) + + data.update(self.get_media_flags(meta)) + + return data + + async def upload(self, meta, disctype): + await self.common.edit_torrent(meta, self.tracker, self.source_flag) + data = await self.fetch_data(meta, disctype) + status_message = '' + + if not meta.get('debug', False): + torrent_id = '' + upload_url = f'{self.base_url}/api.php?api_key={self.api_key}&action=upload' + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = {'file_input': (f'{self.tracker}.placeholder.torrent', torrent_file, 'application/x-bittorrent')} + + async with httpx.AsyncClient(timeout=30) as client: + response = await client.post(url=upload_url, files=files, data=data) + data = response.json() + + if data.get('status') == 200 and 'torrent_id' in data.get('response', {}): + torrent_id = str(data['response']['torrent_id']) + status_message = f'Uploaded successfully. {data}' + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + else: + status_message = f'data error - It may have uploaded, go check. {data}' + return + + await self.common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce, self.torrent_url + torrent_id) + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading.' + + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/HDB.py b/src/trackers/HDB.py index 7fab14991..3f55fa805 100644 --- a/src/trackers/HDB.py +++ b/src/trackers/HDB.py @@ -3,15 +3,18 @@ import re import os from pathlib import Path -import traceback import json import glob +import httpx from unidecode import unidecode from urllib.parse import urlparse, quote from src.trackers.COMMON import COMMON -from src.bbcode import BBCODE -from src.exceptions import * +from src.exceptions import * # noqa F403 from src.console import console +from datetime import datetime +from torf import Torrent +from src.torrentcreate import CustomTorrent, torf_cb, create_torrent + class HDB(): @@ -21,10 +24,9 @@ def __init__(self, config): self.source_flag = 'HDBits' self.username = config['TRACKERS']['HDB'].get('username', '').strip() self.passkey = config['TRACKERS']['HDB'].get('passkey', '').strip() - self.rehost_images = config['TRACKERS']['HDB'].get('img_rehost', False) + self.rehost_images = config['TRACKERS']['HDB'].get('img_rehost', True) self.signature = None self.banned_groups = [""] - async def get_type_category_id(self, meta): cat_id = "EXIT" @@ -42,16 +44,19 @@ async def get_type_category_id(self, meta): # 3 = Documentary if 'documentary' in meta.get("genres", "").lower() or 'documentary' in meta.get("keywords", "").lower(): cat_id = 3 + if meta.get('imdb_info').get('type') is not None and meta.get('imdb_info').get('genres') is not None: + if 'concert' in meta.get('imdb_info').get('type').lower() or ('video' in meta.get('imdb_info').get('type').lower() and 'music' in meta.get('imdb_info').get('genres').lower()): + cat_id = 4 return cat_id async def get_type_codec_id(self, meta): codecmap = { - "AVC" : 1, "H.264" : 1, - "HEVC" : 5, "H.265" : 5, - "MPEG-2" : 2, - "VC-1" : 3, - "XviD" : 4, - "VP9" : 6 + "AVC": 1, "H.264": 1, + "HEVC": 5, "H.265": 5, + "MPEG-2": 2, + "VC-1": 3, + "XviD": 4, + "VP9": 6 } searchcodec = meta.get('video_codec', meta.get('video_encode')) codec_id = codecmap.get(searchcodec, "EXIT") @@ -65,8 +70,8 @@ async def get_type_medium_id(self, meta): # 4 = Capture if meta.get('type', '') == "HDTV": medium_id = 4 - if meta.get('has_encode_settings', False) == True: - medium_id = 3 + if meta.get('has_encode_settings', False) is True: + medium_id = 3 # 3 = Encode if meta.get('type', '') in ("ENCODE", "WEBRIP"): medium_id = 3 @@ -80,18 +85,18 @@ async def get_type_medium_id(self, meta): async def get_res_id(self, resolution): resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', + '8640p': '10', + '4320p': '1', + '2160p': '2', + '1440p': '3', '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', + '1080i': '4', + '720p': '5', + '576p': '6', '576i': '7', - '480p': '8', + '480p': '8', '480i': '9' - }.get(resolution, '10') + }.get(resolution, '10') return resolution_id async def get_tags(self, meta): @@ -99,27 +104,27 @@ async def get_tags(self, meta): # Web Services: service_dict = { - "AMZN" : 28, - "NF" : 29, - "HULU" : 34, - "DSNP" : 33, - "HMAX" : 30, - "ATVP" : 27, - "iT" : 38, - "iP" : 56, - "STAN" : 32, - "PCOK" : 31, - "CR" : 72, - "PMTP" : 69, - "MA" : 77, - "SHO" : 76, - "BCORE" : 66, "CORE" : 66, - "CRKL" : 73, - "FUNI" : 74, - "HLMK" : 71, - "HTSR" : 79, - "CRAV" : 80, - 'MAX' : 88 + "AMZN": 28, + "NF": 29, + "HULU": 34, + "DSNP": 33, + "HMAX": 30, + "ATVP": 27, + "iT": 38, + "iP": 56, + "STAN": 32, + "PCOK": 31, + "CR": 72, + "PMTP": 69, + "MA": 77, + "SHO": 76, + "BCORE": 66, "CORE": 66, + "CRKL": 73, + "FUNI": 74, + "HLMK": 71, + "HTSR": 79, + "CRAV": 80, + 'MAX': 88 } if meta.get('service') in service_dict.keys(): tags.append(service_dict.get(meta['service'])) @@ -127,19 +132,18 @@ async def get_tags(self, meta): # Collections # Masters of Cinema, The Criterion Collection, Warner Archive Collection distributor_dict = { - "WARNER ARCHIVE" : 68, "WARNER ARCHIVE COLLECTION" : 68, "WAC" : 68, - "CRITERION" : 18, "CRITERION COLLECTION" : 18, "CC" : 18, - "MASTERS OF CINEMA" : 19, "MOC" : 19, - "KINO LORBER" : 55, "KINO" : 55, - "BFI VIDEO" : 63, "BFI" : 63, "BRITISH FILM INSTITUTE" : 63, - "STUDIO CANAL" : 65, - "ARROW" : 64 + "WARNER ARCHIVE": 68, "WARNER ARCHIVE COLLECTION": 68, "WAC": 68, + "CRITERION": 18, "CRITERION COLLECTION": 18, "CC": 18, + "MASTERS OF CINEMA": 19, "MOC": 19, + "KINO LORBER": 55, "KINO": 55, + "BFI VIDEO": 63, "BFI": 63, "BRITISH FILM INSTITUTE": 63, + "STUDIO CANAL": 65, + "ARROW": 64 } if meta.get('distributor') in distributor_dict.keys(): tags.append(distributor_dict.get(meta['distributor'])) - - # 4K Remaster, + # 4K Remaster, if "IMAX" in meta.get('edition', ''): tags.append(14) if "OPEN MATTE" in meta.get('edition', '').upper(): @@ -151,20 +155,20 @@ async def get_tags(self, meta): tags.append(7) if "Atmos" in meta['audio']: tags.append(5) - if meta.get('silent', False) == True: - console.print('[yellow]zxx audio track found, suggesting you tag as silent') #57 + if meta.get('silent', False) is True: + console.print('[yellow]zxx audio track found, suggesting you tag as silent') # 57 # Video Metadata - # HDR10, HDR10+, Dolby Vision, 10-bit, + # HDR10, HDR10+, Dolby Vision, 10-bit, if "HDR" in meta.get('hdr', ''): if "HDR10+" in meta['hdr']: - tags.append(25) #HDR10+ + tags.append(25) # HDR10+ else: - tags.append(9) #HDR10 + tags.append(9) # HDR10 if "DV" in meta.get('hdr', ''): - tags.append(6) #DV + tags.append(6) # DV if "HLG" in meta.get('hdr', ''): - tags.append(10) #HLG + tags.append(10) # HLG return tags @@ -179,9 +183,9 @@ async def edit_name(self, meta): if 'HDR10+' not in meta['hdr']: hdb_name = hdb_name.replace('HDR', 'HDR10') if meta.get('type') in ('WEBDL', 'WEBRIP', 'ENCODE'): - hdb_name = hdb_name.replace(meta['audio'], meta['audio'].replace(' ', '', 1).replace('Atmos', '')) + hdb_name = hdb_name.replace(meta['audio'], meta['audio'].replace(' ', '', 1).replace(' Atmos', '')) else: - hdb_name = hdb_name.replace(meta['audio'], meta['audio'].replace('Atmos', '')) + hdb_name = hdb_name.replace(meta['audio'], meta['audio'].replace(' Atmos', '')) hdb_name = hdb_name.replace(meta.get('aka', ''), '') if meta.get('imdb_info'): hdb_name = hdb_name.replace(meta['title'], meta['imdb_info']['aka']) @@ -191,21 +195,18 @@ async def edit_name(self, meta): hdb_name = hdb_name.replace('PQ10', 'HDR') hdb_name = hdb_name.replace('Dubbed', '').replace('Dual-Audio', '') hdb_name = hdb_name.replace('REMUX', 'Remux') + hdb_name = hdb_name.replace('BluRay Remux', 'Remux') + hdb_name = hdb_name.replace('UHD Remux', 'Remux') hdb_name = ' '.join(hdb_name.split()) - hdb_name = re.sub("[^0-9a-zA-ZÀ-ÿ. :&+'\-\[\]]+", "", hdb_name) + hdb_name = re.sub(r"[^0-9a-zA-ZÀ-ÿ. :&+'\-\[\]]+", "", hdb_name) hdb_name = hdb_name.replace(' .', '.').replace('..', '.') - return hdb_name - - - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### + return hdb_name - async def upload(self, meta): + async def upload(self, meta, disctype): common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) await self.edit_desc(meta) + await common.edit_torrent(meta, self.tracker, self.source_flag) hdb_name = await self.edit_name(meta) cat_id = await self.get_type_category_id(meta) codec_id = await self.get_type_codec_id(meta) @@ -216,71 +217,110 @@ async def upload(self, meta): if each == "EXIT": console.print("[bold red]Something didn't map correctly, or this content is not allowed on HDB") return - if "Dual-Audio" in meta['audio'] and meta['is_disc'] not in ("BDMV", "HDDVD", "DVD"): - console.print("[bold red]Dual-Audio Encodes are not allowed") + if "Dual-Audio" in meta['audio']: + if not (meta['anime'] or meta['is_disc']): + console.print("[bold red]Dual-Audio Encodes are not allowed for non-anime and non-disc content") return - # FORM - # file : .torent file (needs renaming) - # name : name - # type_category : get_type_category_id - # type_codec : get_type_codec_id - # type_medium : get_type_medium_id - # type_origin : 0 unless internal (1) - # descr : description - # techinfo : mediainfo only, no bdinfo - # tags[] : get_tags - # imdb : imdb link - # tvdb_id : tvdb id - # season : season number - # episode : episode number - # anidb_id - # POST > upload/upload # Download new .torrent from site - hdb_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent" + hdb_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8').read() + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + torrent = Torrent.read(torrent_path) + + # Check if the piece size exceeds 16 MiB and regenerate the torrent if needed + if torrent.piece_size > 16777216: # 16 MiB in bytes + console.print("[red]Piece size is OVER 16M and does not work on HDB. Generating a new .torrent") + if meta.get('mkbrr', False): + from data.config import config + tracker_url = config['TRACKERS']['HDB'].get('announce_url', "https://fake.tracker").strip() + + # Create the torrent with the tracker URL + torrent_create = f"[{self.tracker}]" + create_torrent(meta, meta['path'], torrent_create, tracker_url=tracker_url) + torrent_filename = "[HDB]" + + await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename=torrent_filename) + else: + if meta['is_disc']: + include = [] + exclude = [] + else: + include = ["*.mkv", "*.mp4", "*.ts"] + exclude = ["*.*", "*sample.mkv", "!sample*.*"] + + # Create a new torrent with piece size explicitly set to 16 MiB + new_torrent = CustomTorrent( + meta=meta, + path=Path(meta['path']), + trackers=["https://fake.tracker"], + source="Audionut", + private=True, + exclude_globs=exclude, # Ensure this is always a list + include_globs=include, # Ensure this is always a list + creation_date=datetime.now(), + comment="Created by Upload Assistant", + created_by="Upload Assistant" + ) + + # Explicitly set the piece size and update metainfo + new_torrent.piece_size = 16777216 # 16 MiB in bytes + new_torrent.metainfo['info']['piece length'] = 16777216 # Ensure 'piece length' is set + + # Validate and write the new torrent + new_torrent.validate_piece_size() + new_torrent.generate(callback=torf_cb, interval=5) + new_torrent.write(torrent_path, overwrite=True) + torrent_filename = "[HDB]" + await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename=torrent_filename) + else: + await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename="BASE") + + # Proceed with the upload process with open(torrent_path, 'rb') as torrentFile: if len(meta['filelist']) == 1: torrentFileName = unidecode(os.path.basename(meta['video']).replace(' ', '.')) else: torrentFileName = unidecode(os.path.basename(meta['path']).replace(' ', '.')) files = { - 'file' : (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent") + 'file': (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorrent") } data = { - 'name' : hdb_name, - 'category' : cat_id, - 'codec' : codec_id, - 'medium' : medium_id, - 'origin' : 0, - 'descr' : hdb_desc.rstrip(), - 'techinfo' : '', - 'tags[]' : hdb_tags, + 'name': hdb_name, + 'category': cat_id, + 'codec': codec_id, + 'medium': medium_id, + 'origin': 0, + 'descr': hdb_desc.rstrip(), + 'techinfo': '', + 'tags[]': hdb_tags, } # If internal, set 1 - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: + if self.config['TRACKERS'][self.tracker].get('internal', False) is True: if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 + data['origin'] = 1 # If not BDMV fill mediainfo if meta.get('is_disc', '') != "BDMV": data['techinfo'] = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8').read() # If tv, submit tvdb_id/season/episode if meta.get('tvdb_id', 0) != 0: data['tvdb'] = meta['tvdb_id'] - if int(meta.get('imdb_id', '').replace('tt', '')) != 0: - data['imdb'] = f"https://www.imdb.com/title/tt{meta.get('imdb_id', '').replace('tt', '')}/", + if meta.get('imdb_id') != 0: + imdbID = f"tt{meta.get('imdb_id'):07d}" + data['imdb'] = f"https://www.imdb.com/title/{imdbID}/", + else: + data['imdb'] = 0 if meta.get('category') == 'TV': data['tvdb_season'] = int(meta.get('season_int', 1)) data['tvdb_episode'] = int(meta.get('episode_int', 1)) # aniDB - url = "https://hdbits.org/upload/upload" # Submit if meta['debug']: console.print(url) console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." else: with requests.Session() as session: cookiefile = f"{meta['base_dir']}/data/cookies/HDB.txt" @@ -291,72 +331,128 @@ async def upload(self, meta): # Match url to verify successful upload match = re.match(r".*?hdbits\.org/details\.php\?id=(\d+)&uploaded=(\d+)", up.url) if match: + meta['tracker_status'][self.tracker]['status_message'] = match.group(0) id = re.search(r"(id=)(\d+)", urlparse(up.url).query).group(2) await self.download_new_torrent(id, torrent_path) else: console.print(data) console.print("\n\n") console.print(up.text) - raise UploadException(f"Upload to HDB Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') + raise UploadException(f"Upload to HDB Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') # noqa F405 return - - async def search_existing(self, meta): + async def search_existing(self, meta, disctype): dupes = [] - console.print("[yellow]Searching for existing torrents on site...") + url = "https://hdbits.org/api/torrents" data = { - 'username' : self.username, - 'passkey' : self.passkey, - 'category' : await self.get_type_category_id(meta), - 'codec' : await self.get_type_codec_id(meta), - 'medium' : await self.get_type_medium_id(meta), - 'search' : meta['resolution'] + 'username': self.username, + 'passkey': self.passkey, + 'category': await self.get_type_category_id(meta), + 'codec': await self.get_type_codec_id(meta), + 'medium': await self.get_type_medium_id(meta) } - if int(meta.get('imdb_id', '0').replace('tt', '0')) != 0: - data['imdb'] = {'id' : meta['imdb_id']} - if int(meta.get('tvdb_id', '0')) != 0: - data['tvdb'] = {'id' : meta['tvdb_id']} - try: - response = requests.get(url=url, data=json.dumps(data)) - response = response.json() - for each in response['data']: - result = each['name'] - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your passkey is incorrect') - await asyncio.sleep(5) - return dupes + if int(meta.get('imdb_id')) != 0: + data['imdb'] = {'id': meta['imdb']} + if int(meta.get('tvdb_id')) != 0: + data['tvdb'] = {'id': meta['tvdb_id']} + + # Build search_terms list + search_terms = [] + has_valid_ids = ((meta.get('category') == 'TV' and meta.get('tvdb_id', 0) == 0 and meta.get('imdb_id', 0) == 0) or + (meta.get('category') == 'MOVIE' and meta.get('imdb_id', 0) == 0)) + + if has_valid_ids: + console.print("[yellow]No IMDb or TVDB ID found, trying other options...") + console.print("[yellow]Double check that the upload does not already exist...") + if meta.get('filename'): + search_terms.append(meta['filename']) + if meta.get('aka'): + aka_clean = meta['aka'].replace('AKA ', '').strip() + if aka_clean: + search_terms.append(aka_clean) + if meta.get('uuid'): + search_terms.append(meta['uuid']) + + # We have ids + if not search_terms: + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(url, json=data) + if response.status_code == 200: + response_data = response.json() + results = response_data.get('data', []) + if results: + for each in results: + result = each['name'] + dupes.append(result) + else: + console.print(f"[bold red]HTTP request failed. Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out while searching for existing torrents.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print("[bold red]Unexpected error occurred while searching torrents.") + console.print(str(e)) + await asyncio.sleep(5) + return dupes + + # Otherwise, search for each term + for search_term in search_terms: + console.print(f"[yellow]Searching HDB for: {search_term}") + data['search'] = search_term - + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(url, json=data) + if response.status_code == 200: + response_data = response.json() + results = response_data.get('data', []) + if results: + for each in results: + result = each['name'] + dupes.append(result) + break # We found results, no need to try other search terms + else: + console.print(f"[bold red]HTTP request failed. Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out while searching for existing torrents.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print("[bold red]Unexpected error occurred while searching torrents.") + console.print(str(e)) + await asyncio.sleep(5) + return dupes async def validate_credentials(self, meta): - vapi = await self.validate_api() + vapi = await self.validate_api() vcookie = await self.validate_cookies(meta) - if vapi != True: + if vapi is not True: console.print('[red]Failed to validate API. Please confirm that the site is up and your passkey is valid.') return False - if vcookie != True: + if vcookie is not True: console.print('[red]Failed to validate cookies. Please confirm that the site is up and your passkey is valid.') return False return True - + async def validate_api(self): url = "https://hdbits.org/api/test" data = { - 'username' : self.username, - 'passkey' : self.passkey + 'username': self.username, + 'passkey': self.passkey } try: r = requests.post(url, data=json.dumps(data)).json() if r.get('status', 5) == 0: return True return False - except: + except Exception: return False - + async def validate_cookies(self, meta): common = COMMON(config=self.config) url = "https://hdbits.org" @@ -365,11 +461,6 @@ async def validate_cookies(self, meta): with requests.Session() as session: session.cookies.update(await common.parseCookieFile(cookiefile)) resp = session.get(url=url) - if meta['debug']: - console.print('[cyan]Cookies:') - console.print(session.cookies.get_dict()) - console.print("\n\n") - console.print(resp.text) if resp.text.find("""Logout""") != -1: return True else: @@ -382,9 +473,9 @@ async def download_new_torrent(self, id, torrent_path): # Get HDB .torrent filename api_url = "https://hdbits.org/api/torrents" data = { - 'username' : self.username, - 'passkey' : self.passkey, - 'id' : id + 'username': self.username, + 'passkey': self.passkey, + 'id': id } r = requests.get(url=api_url, data=json.dumps(data)) filename = r.json()['data'][0]['filename'] @@ -392,8 +483,8 @@ async def download_new_torrent(self, id, torrent_path): # Download new .torrent download_url = f"https://hdbits.org/download.php/{quote(filename)}" params = { - 'passkey' : self.passkey, - 'id' : id + 'passkey': self.passkey, + 'id': id } r = requests.get(url=download_url, params=params) @@ -402,11 +493,11 @@ async def download_new_torrent(self, id, torrent_path): return async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as descfile: + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8').read() + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf-8') as descfile: from src.bbcode import BBCODE # Add This line for all web-dls - if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '' and meta.get('description', None) == None: + if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '' and meta.get('description', None) is None: descfile.write(f"[center][quote]This release is sourced from {meta['service_longname']}[/quote][/center]") bbcode = BBCODE() if meta.get('discs', []) != []: @@ -428,104 +519,330 @@ async def edit_desc(self, meta): descfile.write(f"[quote={os.path.basename(each['vob'])}][{each['vob_mi']}[/quote] [quote={os.path.basename(each['ifo'])}][{each['ifo_mi']}[/quote]\n") descfile.write("\n") desc = base - desc = bbcode.convert_code_to_quote(desc) + # desc = bbcode.convert_code_to_quote(desc) + desc = desc.replace("[code]", "[font=monospace]").replace("[/code]", "[/font]") + desc = desc.replace("[user]", "").replace("[/user]", "") + desc = desc.replace("[left]", "").replace("[/left]", "") + desc = desc.replace("[align=left]", "").replace("[/align]", "") + desc = desc.replace("[right]", "").replace("[/right]", "") + desc = desc.replace("[align=right]", "").replace("[/align]", "") + desc = desc.replace("[sup]", "").replace("[/sup]", "") + desc = desc.replace("[sub]", "").replace("[/sub]", "") + desc = desc.replace("[alert]", "").replace("[/alert]", "") + desc = desc.replace("[note]", "").replace("[/note]", "") + desc = desc.replace("[hr]", "").replace("[/hr]", "") + desc = desc.replace("[h1]", "[u][b]").replace("[/h1]", "[/b][/u]") + desc = desc.replace("[h2]", "[u][b]").replace("[/h2]", "[/b][/u]") + desc = desc.replace("[h3]", "[u][b]").replace("[/h3]", "[/b][/u]") + desc = desc.replace("[ul]", "").replace("[/ul]", "") + desc = desc.replace("[ol]", "").replace("[/ol]", "") + desc = desc.replace("[*]", "* ") desc = bbcode.convert_spoiler_to_hide(desc) desc = bbcode.convert_comparison_to_centered(desc, 1000) - desc = re.sub("(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) + desc = re.sub(r"(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) + desc = re.sub(r"\[/size\]|\[size=\d+\]", "", desc, flags=re.IGNORECASE) descfile.write(desc) - if self.rehost_images == True: + if self.rehost_images is True: console.print("[green]Rehosting Images...") hdbimg_bbcode = await self.hdbimg_upload(meta) - descfile.write(f"{hdbimg_bbcode}") + if hdbimg_bbcode is not None: + if meta.get('comparison', False): + descfile.write("[center]") + descfile.write("[b]") + if meta.get('comparison_groups'): + group_names = [] + sorted_group_indices = sorted(meta['comparison_groups'].keys(), key=lambda x: int(x)) + + for group_idx in sorted_group_indices: + group_data = meta['comparison_groups'][group_idx] + group_name = group_data.get('name', f'Group {group_idx}') + group_names.append(group_name) + + comparison_header = " vs ".join(group_names) + descfile.write(f"Screenshot comparison[/b]\n\n{comparison_header}") + else: + descfile.write("Screenshot comparison") + + descfile.write("\n\n") + descfile.write(f"{hdbimg_bbcode}") + descfile.write("[/center]") + else: + descfile.write(f"{hdbimg_bbcode}") else: images = meta['image_list'] - if len(images) > 0: + if len(images) > 0: descfile.write("[center]") for each in range(len(images[:int(meta['screens'])])): img_url = images[each]['img_url'] web_url = images[each]['web_url'] descfile.write(f"[url={web_url}][img]{img_url}[/img][/url]") descfile.write("[/center]") - if self.signature != None: + if self.signature is not None: descfile.write(self.signature) descfile.close() - async def hdbimg_upload(self, meta): - images = glob.glob(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['filename']}-*.png") + if meta.get('comparison', False): + comparison_path = meta.get('comparison') + if not os.path.isdir(comparison_path): + console.print(f"[red]Comparison path not found: {comparison_path}") + return None + + console.print(f"[green]Uploading comparison images from {comparison_path} to HDB Image Host") + + group_images = {} + max_images_per_group = 0 + + if meta.get('comparison_groups'): + for group_idx, group_data in meta['comparison_groups'].items(): + files_list = group_data.get('files', []) + sorted_files = sorted(files_list, key=lambda f: int(re.match(r"(\d+)-", f).group(1)) if re.match(r"(\d+)-", f) else 0) + + group_images[group_idx] = [] + for filename in sorted_files: + file_path = os.path.join(comparison_path, filename) + if os.path.exists(file_path): + group_images[group_idx].append(file_path) + + max_images_per_group = max(max_images_per_group, len(group_images[group_idx])) + else: + files = [f for f in os.listdir(comparison_path) if f.lower().endswith('.png')] + pattern = re.compile(r"(\d+)-(\d+)-(.+)\.png", re.IGNORECASE) + + for f in files: + match = pattern.match(f) + if match: + first, second, suffix = match.groups() + if second not in group_images: + group_images[second] = [] + file_path = os.path.join(comparison_path, f) + group_images[second].append((int(first), file_path)) + + for group_idx in group_images: + group_images[group_idx].sort(key=lambda x: x[0]) + group_images[group_idx] = [item[1] for item in group_images[group_idx]] + max_images_per_group = max(max_images_per_group, len(group_images[group_idx])) + + # Interleave images for correct ordering + all_image_files = [] + sorted_group_indices = sorted(group_images.keys(), key=lambda x: int(x)) + if len(sorted_group_indices) < 4: + thumb_size = 'w250' + else: + thumb_size = 'w100' + + for image_idx in range(max_images_per_group): + for group_idx in sorted_group_indices: + if image_idx < len(group_images[group_idx]): + all_image_files.append(group_images[group_idx][image_idx]) + + if meta['debug']: + console.print("[cyan]Images will be uploaded in this order:") + for i, path in enumerate(all_image_files): + console.print(f"[cyan]{i}: {os.path.basename(path)}") + else: + thumb_size = 'w300' + image_path = os.path.join(meta['base_dir'], "tmp", os.path.basename(meta['path']), "*.png") + image_glob = glob.glob(image_path) + unwanted_patterns = ["FILE*", "PLAYLIST*", "POSTER*"] + unwanted_files = set() + for pattern in unwanted_patterns: + unwanted_files.update(glob.glob(pattern)) + + image_glob = [file for file in image_glob if file not in unwanted_files] + all_image_files = list(set(image_glob)) + + # At this point, all_image_files contains paths to all images we want to upload + if not all_image_files: + console.print("[red]No images found for upload") + return None + url = "https://img.hdbits.org/upload_api.php" data = { - 'username' : self.username, - 'passkey' : self.passkey, - 'galleryoption' : 1, - 'galleryname' : meta['name'], - 'thumbsize' : 'w300' + 'username': self.username, + 'passkey': self.passkey, + 'galleryoption': '1', + 'galleryname': meta['name'], + 'thumbsize': thumb_size } - files = {} - # Set maximum screenshots to 3 for tv singles and 6 for everthing else - hdbimg_screen_count = 3 if meta['category'] == "TV" and meta.get('tv_pack', 0) == 0 else 6 - if len(images) < hdbimg_screen_count: - hdbimg_screen_count = len(images) - for i in range(hdbimg_screen_count): - files[f'images_files[{i}]'] = open(images[i], 'rb') - r = requests.post(url=url, data=data, files=files) - image_bbcode = r.text - return image_bbcode + if meta.get('comparison', False): + # Use everything + upload_count = len(all_image_files) + else: + # Set max screenshots to 3 for TV singles, 6 otherwise + upload_count = 3 if meta['category'] == "TV" and meta.get('tv_pack', 0) == 0 else 6 + upload_count = min(len(all_image_files), upload_count) + if meta['debug']: + console.print(f"[cyan]Uploading {upload_count} images to HDB Image Host") + files = {} + for i in range(upload_count): + file_path = all_image_files[i] + try: + filename = os.path.basename(file_path) + files[f'images_files[{i}]'] = (filename, open(file_path, 'rb'), 'image/png') + if meta['debug']: + console.print(f"[cyan]Added file {filename} as images_files[{i}]") + except Exception as e: + console.print(f"[red]Failed to open {file_path}: {e}") + continue + + try: + if not files: + console.print("[red]No files to upload") + return None + + if meta['debug']: + console.print(f"[green]Uploading {len(files)} images to HDB...") + response = requests.post(url, data=data, files=files) + + if response.status_code == 200: + console.print("[green]Upload successful!") + bbcode = response.text + if meta.get('comparison', False): + matches = re.findall(r'\[url=.*?\]\[img\].*?\[/img\]\[/url\]', bbcode) + formatted_bbcode = "" + num_groups = len(sorted_group_indices) if sorted_group_indices else 3 + + for i in range(0, len(matches), num_groups): + line = " ".join(matches[i:i+num_groups]) + if i + num_groups < len(matches): + formatted_bbcode += line + "\n" + else: + formatted_bbcode += line + + bbcode = formatted_bbcode + + if meta['debug']: + console.print(f"[cyan]Response formatted with {num_groups} images per line") + + return bbcode + else: + console.print(f"[red]Upload failed with status code {response.status_code}") + return None + except requests.RequestException as e: + console.print(f"[red]HTTP Request failed: {e}") + return None + finally: + # Close files to prevent resource leaks + for f in files.values(): + f[1].close() async def get_info_from_torrent_id(self, hdb_id): - hdb_imdb = hdb_name = hdb_torrenthash = None + hdb_imdb = hdb_tvdb = hdb_name = hdb_torrenthash = hdb_description = None url = "https://hdbits.org/api/torrents" data = { - "username" : self.username, - "passkey" : self.passkey, - "id" : hdb_id + "username": self.username, + "passkey": self.passkey, + "id": hdb_id } - response = requests.get(url, json=data) - if response.ok: - try: - response = response.json() - if response['data'] != []: - hdb_imdb = response['data'][0].get('imdb', {'id' : None}).get('id') - hdb_tvdb = response['data'][0].get('tvdb', {'id' : None}).get('id') - hdb_name = response['data'][0]['name'] - hdb_torrenthash = response['data'][0]['hash'] - - except: - console.print_exception() - else: - console.print("Failed to get info from HDB ID. Either the site is down or your credentials are invalid") - return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash - async def search_filename(self, filelist): - hdb_imdb = hdb_tvdb = hdb_name = hdb_torrenthash = hdb_id = None + try: + response = requests.post(url, json=data) + if response.ok: + response_json = response.json() + + if response_json.get('status') == 0 and response_json.get('data'): + first_entry = response_json['data'][0] + + hdb_imdb = int(first_entry.get('imdb', {}).get('id') or 0) + hdb_tvdb = int(first_entry.get('tvdb', {}).get('id') or 0) + hdb_name = first_entry.get('name', None) + hdb_torrenthash = first_entry.get('hash', None) + hdb_description = first_entry.get('descr') + + else: + status_code = response_json.get('status', 'unknown') + message = response_json.get('message', 'No error message provided') + console.print(f"[red]API returned error status {status_code}: {message}[/red]") + + except requests.exceptions.RequestException as e: + console.print(f"[red]Request error: {e}[/red]") + except Exception as e: + console.print(f"[red]Unexpected error: {e}[/red]") + console.print_exception() + + return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_description + + async def search_filename(self, search_term, search_file_folder, meta): + hdb_imdb = hdb_tvdb = hdb_name = hdb_torrenthash = hdb_description = hdb_id = None url = "https://hdbits.org/api/torrents" - data = { - "username" : self.username, - "passkey" : self.passkey, - "limit" : 100, - "file_in_torrent" : os.path.basename(filelist[0]) - } - response = requests.get(url, json=data) - console.print(f"[green]Searching HDB for: [bold yellow]{os.path.basename(filelist[0])}[/bold yellow]") - if response.ok: + + # Handle disc case + if search_file_folder == 'folder' and meta.get('is_disc'): + bd_summary_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid'], 'BD_SUMMARY_00.txt') + bd_summary = None + + # Parse the BD_SUMMARY_00.txt file to extract the Disc Title try: - response = response.json() - if response['data'] != []: - for each in response['data']: - if each['numfiles'] == len(filelist): - hdb_imdb = each.get('imdb', {'id' : None}).get('id') - hdb_tvdb = each.get('tvdb', {'id' : None}).get('id') - hdb_name = each['name'] - hdb_torrenthash = each['hash'] - hdb_id = each['id'] - console.print(f'[bold green]Matched release with HDB ID: [yellow]{hdb_id}[/yellow][/bold green]') - return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_id - except: - console.print_exception() - else: - console.print("Failed to get info from HDB ID. Either the site is down or your credentials are invalid") - console.print(f'[yellow]Could not find a matching release on HDB') - return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_id \ No newline at end of file + with open(bd_summary_path, 'r', encoding='utf-8') as file: + for line in file: + if "Disc Title:" in line: + bd_summary = line.split("Disc Title:")[1].strip() + break + + if bd_summary: + data = { + "username": self.username, + "passkey": self.passkey, + "limit": 100, + "search": bd_summary # Using the Disc Title for search + } + console.print(f"[green]Searching HDB for disc title: [bold yellow]{bd_summary}[/bold yellow]") + # console.print(f"[yellow]Using this data: {data}") + else: + console.print(f"[red]Error: 'Disc Title' not found in {bd_summary_path}[/red]") + return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_description, hdb_id + + except FileNotFoundError: + console.print(f"[red]Error: File not found at {bd_summary_path}[/red]") + return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_description, hdb_id + + else: # Handling non-disc case + data = { + "username": self.username, + "passkey": self.passkey, + "limit": 100, + "file_in_torrent": os.path.basename(search_term) + } + console.print(f"[green]Searching HDB for file: [bold yellow]{os.path.basename(search_term)}[/bold yellow]") + # console.print(f"[yellow]Using this data: {data}") + + try: + response = requests.post(url, json=data) + if response.ok: + try: + response_json = response.json() + # console.print(f"[green]HDB API response: {response_json}[/green]") + + if 'data' not in response_json: + console.print(f"[red]Error: 'data' key not found or empty in HDB API response. Full response: {response_json}[/red]") + return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_id + + for each in response_json['data']: + hdb_imdb = int(each.get('imdb', {}).get('id') or 0) + hdb_tvdb = int(each.get('tvdb', {}).get('id') or 0) + hdb_name = each.get('name', None) + hdb_torrenthash = each.get('hash', None) + hdb_id = each.get('id', None) + hdb_description = each.get('descr') + + console.print(f'[bold green]Matched release with HDB ID: [yellow]https://hdbits.org/details.php?id={hdb_id}[/yellow][/bold green]') + + return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_description, hdb_id + + console.print('[yellow]No data found in the HDB API response[/yellow]') + + except (ValueError, KeyError, TypeError) as e: + console.print_exception() + console.print(f"[red]Failed to parse HDB API response. Error: {str(e)}[/red]") + else: + console.print(f"[red]Failed to get info from HDB. Status code: {response.status_code}, Reason: {response.reason}[/red]") + + except requests.exceptions.RequestException as e: + console.print(f"[red]Request error: {str(e)}[/red]") + + console.print('[yellow]Could not find a matching release on HDB[/yellow]') + return hdb_imdb, hdb_tvdb, hdb_name, hdb_torrenthash, hdb_description, hdb_id diff --git a/src/trackers/HDS.py b/src/trackers/HDS.py new file mode 100644 index 000000000..c9134080f --- /dev/null +++ b/src/trackers/HDS.py @@ -0,0 +1,396 @@ +# -*- coding: utf-8 -*- +import glob +import httpx +import os +import platform +import re +from src.trackers.COMMON import COMMON +from bs4 import BeautifulSoup +from pymediainfo import MediaInfo +from src.console import console + + +class HDS: + def __init__(self, config): + self.config = config + self.common = COMMON(config) + self.tracker = 'HDS' + self.source_flag = 'HD-Space' + self.banned_groups = [''] + self.base_url = 'https://hd-space.org' + self.torrent_url = 'https://hd-space.org/index.php?page=torrent-details&id=' + self.announce = self.config['TRACKERS'][self.tracker]['announce_url'] + self.session = httpx.AsyncClient(headers={ + 'User-Agent': f"Upload Assistant/2.3 ({platform.system()} {platform.release()})" + }, timeout=30) + self.signature = "[center][url=https://github.com/Audionut/Upload-Assistant]Created by Upload Assistant[/url][/center]" + + async def load_cookies(self, meta): + cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/HDS.txt") + if not os.path.exists(cookie_file): + console.print(f'[bold red]Cookie file for {self.tracker} not found: {cookie_file}[/bold red]') + return False + + self.session.cookies = await self.common.parseCookieFile(cookie_file) + + async def validate_credentials(self, meta): + await self.load_cookies(meta) + try: + test_url = f'{self.base_url}/index.php?' + + params = { + 'page': 'upload' + } + + response = await self.session.get(test_url, params=params) + + if response.status_code == 200 and 'index.php?page=upload' in str(response.url): + return True + else: + console.print(f'[bold red]Failed to validate {self.tracker} credentials. The cookie may be expired.[/bold red]') + return False + except Exception as e: + console.print(f'[bold red]Error validating {self.tracker} credentials: {e}[/bold red]') + return False + + async def generate_description(self, meta): + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + description_parts = [] + + # MediaInfo/BDInfo + tech_info = '' + if meta.get('is_disc') == 'BDMV': + bd_summary_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt" + if os.path.exists(bd_summary_file): + with open(bd_summary_file, 'r', encoding='utf-8') as f: + tech_info = f.read() + + if not meta.get('is_disc'): + video_file = meta['filelist'][0] + mi_template = os.path.abspath(f"{meta['base_dir']}/data/templates/MEDIAINFO.txt") + if os.path.exists(mi_template): + try: + media_info = MediaInfo.parse(video_file, output='STRING', full=False, mediainfo_options={'inform': f'file://{mi_template}'}) + tech_info = str(media_info) + except Exception: + console.print('[bold red]Could not find the MediaInfo template[/bold red]') + mi_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + if os.path.exists(mi_file_path): + with open(mi_file_path, 'r', encoding='utf-8') as f: + tech_info = f.read() + else: + console.print('[bold yellow]Using normal MediaInfo for the description.[/bold yellow]') + mi_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + if os.path.exists(mi_file_path): + with open(mi_file_path, 'r', encoding='utf-8') as f: + tech_info = f.read() + + if tech_info: + description_parts.append(f'[pre]{tech_info}[/pre]') + + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as f: + manual_desc = f.read() + description_parts.append(manual_desc) + + # Screenshots + images = meta.get('image_list', []) + screenshots_block = '[center]\n' + for image in images: + img_url = image['img_url'] + web_url = image['web_url'] + screenshots_block += f'[url={web_url}][img]{img_url}[/img][/url]' + screenshots_block += '\n[/center]' + description_parts.append(screenshots_block) + + if self.signature: + description_parts.append(self.signature) + + final_description = '\n\n'.join(filter(None, description_parts)) + from src.bbcode import BBCODE + bbcode = BBCODE() + desc = final_description + desc = desc.replace('[user]', '').replace('[/user]', '') + desc = desc.replace('[align=left]', '').replace('[/align]', '') + desc = desc.replace('[right]', '').replace('[/right]', '') + desc = desc.replace('[align=right]', '').replace('[/align]', '') + desc = desc.replace('[sup]', '').replace('[/sup]', '') + desc = desc.replace('[sub]', '').replace('[/sub]', '') + desc = desc.replace('[alert]', '').replace('[/alert]', '') + desc = desc.replace('[note]', '').replace('[/note]', '') + desc = desc.replace('[hr]', '').replace('[/hr]', '') + desc = desc.replace('[h1]', '[u][b]').replace('[/h1]', '[/b][/u]') + desc = desc.replace('[h2]', '[u][b]').replace('[/h2]', '[/b][/u]') + desc = desc.replace('[h3]', '[u][b]').replace('[/h3]', '[/b][/u]') + desc = desc.replace('[ul]', '').replace('[/ul]', '') + desc = desc.replace('[ol]', '').replace('[/ol]', '') + desc = desc.replace('[hide]', '').replace('[/hide]', '') + desc = re.sub(r'\[center\]\[spoiler=.*? NFO:\]\[code\](.*?)\[/code\]\[/spoiler\]\[/center\]', r'', desc, flags=re.DOTALL) + desc = re.sub(r'\[img(?:[^\]]*)\]', '[img]', desc, flags=re.IGNORECASE) + desc = bbcode.convert_comparison_to_centered(desc, 1000) + desc = bbcode.remove_spoiler(desc) + desc = re.sub(r'\n{3,}', '\n\n', desc) + + with open(final_desc_path, 'w', encoding='utf-8') as f: + f.write(desc) + + return desc + + async def search_existing(self, meta, disctype): + images = meta.get('image_list', []) + if not images or len(images) < 3: + console.print(f'{self.tracker}: At least 3 screenshots are required to upload.') + meta['skipping'] = f'{self.tracker}' + return + + dupes = [] + imdb_id = meta.get('imdb', '') + if imdb_id == '0': + console.print(f'IMDb ID not found, cannot search for duplicates on {self.tracker}.') + return dupes + + search_url = f'{self.base_url}/index.php?' + + params = { + 'page': 'torrents', + 'search': imdb_id, + 'active': '0', + 'options': '2' + } + + try: + response = await self.session.get(search_url, params=params) + response.raise_for_status() + soup = BeautifulSoup(response.text, 'html.parser') + + all_tables = soup.find_all('table', class_='lista') + + torrent_rows = [] + + for table in all_tables: + recommend_header = table.find('td', class_='block', string='Our Team Recommend') + if recommend_header: + continue + + rows_in_table = table.select('tr:has(td.lista)') + torrent_rows.extend(rows_in_table) + + for row in torrent_rows: + name_tag = row.select_one('td:nth-child(2) > a[href*="page=torrent-details&id="]') + name = name_tag.get_text(strip=True) if name_tag else 'Unknown Name' + + link_tag = name_tag + torrent_link = None + if link_tag and 'href' in link_tag.attrs: + torrent_link = f'{self.base_url}/{link_tag["href"]}' + + duplicate_entry = { + 'name': name, + 'size': None, + 'link': torrent_link + } + dupes.append(duplicate_entry) + + except Exception as e: + console.print(f'[bold red]Error searching for duplicates on {self.tracker}: {e}[/bold red]') + + return dupes + + async def get_category_id(self, meta): + resolution = meta.get('resolution') + category = meta.get('category') + type_ = meta.get('type') + is_disc = meta.get('is_disc') + genres = meta.get('genres', '').lower() + keywords = meta.get('keywords', '').lower() + is_anime = meta.get('anime') + + if is_disc == 'BDMV': + return 15 # Blu-Ray + if type_ == 'REMUX': + return 40 # Remux + + category_map = { + 'MOVIE': { + '2160p': 46, + '1080p': 19, '1080i': 19, + '720p': 18 + }, + 'TV': { + '2160p': 45, + '1080p': 22, '1080i': 22, + '720p': 21 + }, + 'DOCUMENTARY': { + '2160p': 47, + '1080p': 25, '1080i': 25, + '720p': 24 + }, + 'ANIME': { + '2160p': 48, + '1080p': 28, '1080i': 28, + '720p': 27 + } + } + + if 'documentary' in genres or 'documentary' in keywords: + return category_map['DOCUMENTARY'].get(resolution, 38) + if is_anime: + return category_map['ANIME'].get(resolution, 38) + + if category in category_map: + return category_map[category].get(resolution, 38) + + return 38 + + async def get_requests(self, meta): + if not self.config['DEFAULT'].get('search_requests', False) and not meta.get('search_requests', False): + return False + else: + try: + query = meta['title'] + search_url = f'{self.base_url}/index.php?' + + params = { + 'page': 'viewrequests', + 'search': query, + 'filter': 'true' + } + + response = await self.session.get(search_url, params=params, cookies=self.session.cookies) + response.raise_for_status() + response_results_text = response.text + + soup = BeautifulSoup(response_results_text, 'html.parser') + request_rows = soup.select('form[action="index.php?page=takedelreq"] table.lista tr') + + results = [] + for row in request_rows: + if row.find('td', class_='header'): + continue + + name_element = row.select_one('td.lista a b') + if not name_element: + continue + + name = name_element.text.strip() + link_element = name_element.find_parent('a') + link = link_element['href'] if link_element else None + + results.append({ + 'Name': name, + 'Link': link, + }) + + if results: + message = f"\n{self.tracker}: [bold yellow]Your upload may fulfill the following request(s), check it out:[/bold yellow]\n\n" + for r in results: + message += f"[bold green]Name:[/bold green] {r['Name']}\n" + message += f"[bold green]Link:[/bold green] {self.base_url}/{r['Link']}\n\n" + console.print(message) + + return results + + except Exception as e: + print(f'An error occurred while fetching requests: {e}') + return [] + + async def get_nfo(self, meta): + nfo_dir = os.path.join(meta['base_dir'], 'tmp', meta['uuid']) + nfo_files = glob.glob(os.path.join(nfo_dir, '*.nfo')) + + if nfo_files: + nfo_path = nfo_files[0] + + return { + 'nfo': ( + os.path.basename(nfo_path), + open(nfo_path, 'rb'), + 'application/octet-stream' + ) + } + return {} + + async def fetch_data(self, meta): + data = { + 'category': await self.get_category_id(meta), + 'filename': meta['name'], + 'genre': meta.get('genres', ''), + 'imdb': meta.get('imdb', ''), + 'info': await self.generate_description(meta), + 'nuk_rea': '', + 'nuk': 'false', + 'req': 'false', + 'submit': 'Send', + 't3d': 'true' if '3D' in meta.get('3d', '') else 'false', + 'user_id': '', + 'youtube_video': meta.get('youtube', ''), + } + + # Anon + anon = not (meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False)) + if anon: + data.update({ + 'anonymous': 'true' + }) + else: + data.update({ + 'anonymous': 'false' + }) + + return data + + async def upload(self, meta, disctype): + await self.load_cookies(meta) + await self.common.edit_torrent(meta, self.tracker, self.source_flag) + data = await self.fetch_data(meta) + requests = await self.get_requests(meta) + status_message = '' + + if not meta.get('debug', False): + torrent_id = '' + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + upload_url = f"{self.base_url}/index.php?" + params = { + 'page': 'upload' + } + + with open(torrent_path, 'rb') as torrent_file: + files = { + 'torrent': (f'[{self.tracker}].torrent', torrent_file, 'application/x-bittorrent'), + } + nfo = await self.get_nfo(meta) + if nfo: + files['nfo'] = nfo['nfo'] + + response = await self.session.post(upload_url, data=data, params=params, files=files) + + if 'download.php?id=' in response.text: + status_message = 'Torrent uploaded successfully.' + + # Find the torrent id + match = re.search(r'download\.php\?id=([^&]+)', response.text) + if match: + torrent_id = match.group(1) + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + if requests: + status_message += ' Your upload may fulfill existing requests, check prior console logs.' + + else: + status_message = 'data error - The upload appears to have failed. It may have uploaded, go check.' + + response_save_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(response_save_path, 'w', encoding='utf-8') as f: + f.write(response.text) + console.print(f'Upload failed, HTML response was saved to: {response_save_path}') + + await self.common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce, self.torrent_url + torrent_id) + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading' + + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/HDT.py b/src/trackers/HDT.py index 6b9fa0320..130d1fb06 100644 --- a/src/trackers/HDT.py +++ b/src/trackers/HDT.py @@ -1,32 +1,95 @@ -import requests -import asyncio -import re +# -*- coding: utf-8 -*- +import aiofiles +import http.cookiejar +import httpx import os -import json -import glob -import cli_ui -import pickle -import distutils -from pathlib import Path +import re from bs4 import BeautifulSoup -from unidecode import unidecode from pymediainfo import MediaInfo - -from src.trackers.COMMON import COMMON -from src.exceptions import * from src.console import console +from src.trackers.COMMON import COMMON +from urllib.parse import urlparse + -class HDT(): - +class HDT: def __init__(self, config): self.config = config + self.common = COMMON(config) self.tracker = 'HDT' self.source_flag = 'hd-torrents.org' - self.username = config['TRACKERS'][self.tracker].get('username', '').strip() - self.password = config['TRACKERS'][self.tracker].get('password', '').strip() - self.signature = None - self.banned_groups = [""] - + + url_from_config = self.config['TRACKERS'][self.tracker].get('url') + parsed_url = urlparse(url_from_config) + self.config_url = parsed_url.netloc + self.base_url = f'https://{self.config_url}' + + self.torrent_url = f'{self.base_url}/details.php?id=' + self.announce_url = self.config['TRACKERS'][self.tracker]['announce_url'] + self.banned_groups = [] + self.ua_name = f'Upload Assistant {self.common.get_version()}'.strip() + self.signature = f'\n[center][url=https://github.com/Audionut/Upload-Assistant]Created by {self.ua_name}[/url][/center]' + self.session = httpx.AsyncClient(headers={ + 'User-Agent': self.ua_name + }, timeout=60.0) + + async def load_cookies(self, meta): + cookie_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/{self.tracker}.txt") + self.cookie_jar = http.cookiejar.MozillaCookieJar(cookie_file) + + try: + self.cookie_jar.load(ignore_discard=True, ignore_expires=True) + except FileNotFoundError: + console.print(f'{self.tracker}: [bold red]Cookie file for {self.tracker} not found: {cookie_file}[/bold red]') + + self.session.cookies = self.cookie_jar + + async def save_cookies(self): + if self.cookie_jar is None: + console.print(f'{self.tracker}: Cookie jar not initialized, cannot save cookies.') + return + + try: + self.cookie_jar.save(ignore_discard=True, ignore_expires=True) + except Exception as e: + console.print(f'{self.tracker}: Failed to update the cookie file: {e}') + + async def validate_credentials(self, meta): + await self.load_cookies(meta) + try: + upload_page_url = f'{self.base_url}/upload.php' + response = await self.session.get(upload_page_url) + response.raise_for_status() + + if 'Create account' in response.text: + console.print(f'{self.tracker}: Validation failed. The cookie appears to be expired or invalid.') + return False + + auth_match = re.search(r'name="csrfToken" value="([^"]+)"', response.text) + + if not auth_match: + console.print(f"{self.tracker}: Validation failed. Could not find 'auth' token on upload page.") + console.print('This can happen if the site HTML has changed or if the login failed silently..') + + failure_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + os.makedirs(os.path.dirname(failure_path), exist_ok=True) + with open(failure_path, 'w', encoding='utf-8') as f: + f.write(response.text) + console.print(f'The server response was saved to {failure_path} for analysis.') + return False + + await self.save_cookies() + return str(auth_match.group(1)) + + except httpx.TimeoutException: + console.print(f'{self.tracker}: Error in {self.tracker}: Timeout while trying to validate credentials.') + return False + except httpx.HTTPStatusError as e: + console.print(f'{self.tracker}: HTTP error validating credentials for {self.tracker}: Status {e.response.status_code}.') + return False + except httpx.RequestError as e: + console.print(f'{self.tracker}: Network error while validating credentials for {self.tracker}: {e.__class__.__name__}.') + return False + async def get_category_id(self, meta): if meta['category'] == 'MOVIE': # BDMV @@ -37,7 +100,7 @@ async def get_category_id(self, meta): if meta['resolution'] in ('1080p', '1080i'): # 1 = Movie/Blu-Ray cat_id = 1 - + # REMUX if meta.get('type', '') == 'REMUX': if meta.get('uhd', '') == 'UHD' and meta['resolution'] == '2160p': @@ -46,7 +109,7 @@ async def get_category_id(self, meta): else: # 2 = Movie/Remux cat_id = 2 - + # REST OF THE STUFF if meta.get('type', '') not in ("DISC", "REMUX"): if meta['resolution'] == '2160p': @@ -68,7 +131,7 @@ async def get_category_id(self, meta): if meta['resolution'] in ('1080p', '1080i'): # 59 = TV Show/Blu-ray cat_id = 59 - + # REMUX if meta.get('type', '') == 'REMUX': if meta.get('uhd', '') == 'UHD' and meta['resolution'] == '2160p': @@ -77,7 +140,7 @@ async def get_category_id(self, meta): else: # 60 = TV Show/Remux cat_id = 60 - + # REST OF THE STUFF if meta.get('type', '') not in ("DISC", "REMUX"): if meta['resolution'] == '2160p': @@ -89,251 +152,269 @@ async def get_category_id(self, meta): elif meta['resolution'] == '720p': # 38 = TV Show/720p cat_id = 38 - - return cat_id - - + return cat_id async def edit_name(self, meta): hdt_name = meta['name'] - if meta['category'] == "TV" and meta.get('tv_pack', 0) == 0 and meta.get('episode_title_storage', '').strip() != '': - hdt_name = hdt_name.replace(meta['episode'], f"{meta['episode']} {meta['episode_title_storage']}") if meta.get('type') in ('WEBDL', 'WEBRIP', 'ENCODE'): hdt_name = hdt_name.replace(meta['audio'], meta['audio'].replace(' ', '', 1)) if 'DV' in meta.get('hdr', ''): hdt_name = hdt_name.replace(' DV ', ' DoVi ') - + if 'BluRay REMUX' in hdt_name: + hdt_name = hdt_name.replace('BluRay REMUX', 'Blu-ray Remux') + hdt_name = ' '.join(hdt_name.split()) - hdt_name = re.sub("[^0-9a-zA-ZÀ-ÿ. &+'\-\[\]]+", "", hdt_name) + hdt_name = re.sub(r"[^0-9a-zA-ZÀ-ÿ. &+'\-\[\]]+", "", hdt_name) hdt_name = hdt_name.replace(':', '').replace('..', ' ').replace(' ', ' ') return hdt_name - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await self.edit_desc(meta) - hdt_name = await self.edit_name(meta) - cat_id = await self.get_category_id(meta) - - # Confirm the correct naming order for HDT - cli_ui.info(f"HDT name: {hdt_name}") - if meta.get('unattended', False) == False: - hdt_confirm = cli_ui.ask_yes_no("Correct?", default=False) - if hdt_confirm != True: - hdt_name_manually = cli_ui.ask_string("Please enter a proper name", default="") - if hdt_name_manually == "": - console.print('No proper name given') - console.print("Aborting...") - return + def get_links(self, movie, subheading, heading_end): + description = "" + description += "\n" + subheading + "Links" + heading_end + "\n" + if 'IMAGES' in self.config: + if movie['tmdb'] != 0: + description += f" [URL=https://www.themoviedb.org/{str(movie['category'].lower())}/{str(movie['tmdb'])}][img]{self.config['IMAGES']['tmdb_75']}[/img][/URL]" + if movie['tvdb_id'] != 0: + description += f" [URL=https://www.thetvdb.com/?id={str(movie['tvdb_id'])}&tab=series][img]{self.config['IMAGES']['tvdb_75']}[/img][/URL]" + if movie['tvmaze_id'] != 0: + description += f" [URL=https://www.tvmaze.com/shows/{str(movie['tvmaze_id'])}][img]{self.config['IMAGES']['tvmaze_75']}[/img][/URL]" + if movie['mal_id'] != 0: + description += f" [URL=https://myanimelist.net/anime/{str(movie['mal_id'])}][img]{self.config['IMAGES']['mal_75']}[/img][/URL]" + else: + if movie['tmdb'] != 0: + description += f"\nhttps://www.themoviedb.org/{str(movie['category'].lower())}/{str(movie['tmdb'])}" + if movie['tvdb_id'] != 0: + description += f"\nhttps://www.thetvdb.com/?id={str(movie['tvdb_id'])}&tab=series" + if movie['tvmaze_id'] != 0: + description += f"\nhttps://www.tvmaze.com/shows/{str(movie['tvmaze_id'])}" + if movie['mal_id'] != 0: + description += f"\nhttps://myanimelist.net/anime/{str(movie['mal_id'])}" + + description += "\n\n" + return description + + async def edit_desc(self, meta): + subheading = '[COLOR=RED][size=4]' + heading_end = '[/size][/COLOR]' + description_parts = [] + + desc_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid'], 'DESCRIPTION.txt') + async with aiofiles.open(desc_path, 'r', encoding='utf-8') as f: + description_parts.append(await f.read()) + + media_info_block = None + + if meta.get('is_disc') == 'BDMV': + bd_info = meta.get('discs', [{}])[0].get('summary', '') + if bd_info: + media_info_block = f'[left][font=consolas]{bd_info}[/font][/left]' + else: + if meta.get('filelist'): + video = meta['filelist'][0] + mi_template_path = os.path.abspath(os.path.join(meta['base_dir'], 'data', 'templates', 'MEDIAINFO.txt')) + + if os.path.exists(mi_template_path): + media_info = MediaInfo.parse( + video, + output='STRING', + full=False, + mediainfo_options={'inform': f'file://{mi_template_path}'} + ) + media_info = media_info.replace('\r\n', '\n') + media_info_block = f'[left][font=consolas]{media_info}[/font][/left]' else: - hdt_name = hdt_name_manually - - # Upload - hdt_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', newline='').read() - torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent" - - with open(torrent_path, 'rb') as torrentFile: - torrentFileName = unidecode(hdt_name) - files = { - 'torrent' : (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent") + console.print('[bold red]Couldn\'t find the MediaInfo template') + console.print('[green]Using normal MediaInfo for the description.') + + cleanpath_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid'], 'MEDIAINFO_CLEANPATH.txt') + async with aiofiles.open(cleanpath_path, 'r', encoding='utf-8') as MI: + media_info_block = f'[left][font=consolas]{await MI.read()}[/font][/left]' + + if media_info_block: + description_parts.append(media_info_block) + + description_parts.append(self.get_links(meta, subheading, heading_end)) + + images = meta.get('image_list', []) + if images: + screenshots_block = '' + for i, image in enumerate(images, start=1): + img_url = image.get('img_url', '') + raw_url = image.get('raw_url', '') + + screenshots_block += ( + f" " + ) + + if i % 3 == 0: + screenshots_block += '\n' + description_parts.append(f'[center]{screenshots_block}[/center]') + + description_parts.append(self.signature) + + final_description = '\n'.join(description_parts) + + output_path = os.path.join(meta['base_dir'], 'tmp', meta['uuid'], f'[{self.tracker}]DESCRIPTION.txt') + async with aiofiles.open(output_path, 'w', encoding='utf-8') as description_file: + await description_file.write(final_description) + + return final_description + + async def search_existing(self, meta, disctype): + if meta['resolution'] not in ['2160p', '1080p', '1080i', '720p']: + console.print('[bold red]Resolution must be at least 720p resolution for HDT.') + meta['skipping'] = f'{self.tracker}' + return [] + + # Ensure we have valid credentials and auth_token before searching + if not hasattr(self, 'auth_token') or not self.auth_token: + credentials_valid = await self.validate_credentials(meta) + if not credentials_valid: + console.print(f'[bold red]{self.tracker}: Failed to validate credentials for search.') + return [] + + search_url = f'{self.base_url}/torrents.php?' + if int(meta.get('imdb_id', 0)) != 0: + imdbID = f"tt{meta['imdb']}" + params = { + 'csrfToken': meta[f'{self.tracker}_secret_token'], + 'search': imdbID, + 'active': '0', + 'options': '2', + 'category[]': await self.get_category_id(meta) } - data = { - 'filename' : hdt_name, - 'category' : cat_id, - 'info' : hdt_desc.strip() + else: + params = { + 'csrfToken': meta[f'{self.tracker}_secret_token'], + 'search': meta['title'], + 'category[]': await self.get_category_id(meta), + 'options': '3' } - # 3D - if "3D" in meta.get('3d', ''): - data['3d'] = 'true' - - # HDR - if "HDR" in meta.get('hdr', ''): - if "HDR10+" in meta['hdr']: - data['HDR10'] = 'true' - data['HDR10Plus'] = 'true' - else: - data['HDR10'] = 'true' - if "DV" in meta.get('hdr', ''): - data['DolbyVision'] = 'true' - - # IMDB - if int(meta.get('imdb_id', '').replace('tt', '')) != 0: - data['infosite'] = f"https://www.imdb.com/title/tt{meta['imdb_id']}/" - - # Full Season Pack - if int(meta.get('tv_pack', '0')) != 0: - data['season'] = 'true' - else: - data['season'] = 'false' - - # Anonymous check - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - data['anonymous'] = 'false' - else: - data['anonymous'] = 'true' + results = [] - # Send - url = "https://hd-torrents.org/upload.php" - if meta['debug']: - console.print(url) - console.print(data) - else: - with requests.Session() as session: - cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/HDT.txt") - - session.cookies.update(await common.parseCookieFile(cookiefile)) - up = session.post(url=url, data=data, files=files) - torrentFile.close() - - # Match url to verify successful upload - search = re.search(r"download\.php\?id\=([a-z0-9]+)", up.text).group(1) - if search: - # modding existing torrent for adding to client instead of downloading torrent from site. - await common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.config['TRACKERS']['HDT'].get('my_announce_url'), "https://hd-torrents.org/details.php?id=" + search) - else: - console.print(data) - console.print("\n\n") - console.print(up.text) - raise UploadException(f"Upload to HDT Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') - return - - - async def search_existing(self, meta): - dupes = [] - with requests.Session() as session: - common = COMMON(config=self.config) - cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/HDT.txt") - session.cookies.update(await common.parseCookieFile(cookiefile)) - - search_url = f"https://hd-torrents.org/torrents.php" - csrfToken = await self.get_csrfToken(session, search_url) - if int(meta['imdb_id'].replace('tt', '')) != 0: - params = { - 'csrfToken' : csrfToken, - 'search' : meta['imdb_id'], - 'active' : '0', - 'options' : '2', - 'category[]' : await self.get_category_id(meta) - } + try: + response = await self.session.get(search_url, params=params) + soup = BeautifulSoup(response.text, 'html.parser') + rows = soup.find_all('tr') + + for row in rows: + if row.find('td', class_='mainblockcontent', string='Filename') is not None: + continue + + name_tag = row.find('a', href=lambda href: href and href.startswith('details.php?id=')) + + name = name_tag.text.strip() if name_tag else None + link = f'{self.base_url}/{name_tag["href"]}' if name_tag else None + size = None + + cells = row.find_all('td', class_='mainblockcontent') + for cell in cells: + cell_text = cell.text.strip() + if 'GiB' in cell_text or 'MiB' in cell_text: + size = cell_text + break + + if name: + results.append({ + 'name': name, + 'size': size, + 'link': link + }) + + except httpx.TimeoutException: + console.print(f'{self.tracker}: Timeout while searching for existing torrents.') + return [] + except httpx.HTTPStatusError as e: + console.print(f'{self.tracker}: HTTP error while searching: Status {e.response.status_code}.') + return [] + except httpx.RequestError as e: + console.print(f'{self.tracker}: Network error while searching: {e.__class__.__name__}.') + return [] + except Exception as e: + console.print(f'{self.tracker}: Unexpected error while searching: {e}') + return [] + + return results + + async def get_data(self, meta): + await self.load_cookies(meta) + data = { + 'filename': await self.edit_name(meta), + 'category': await self.get_category_id(meta), + 'info': await self.edit_desc(meta), + 'csrfToken': meta[f'{self.tracker}_secret_token'], + } + + # 3D + if "3D" in meta.get('3d', ''): + data['3d'] = 'true' + + # HDR + if "HDR" in meta.get('hdr', ''): + if "HDR10+" in meta['hdr']: + data['HDR10'] = 'true' + data['HDR10Plus'] = 'true' else: - params = { - 'csrfToken' : csrfToken, - 'search' : meta['title'], - 'category[]' : await self.get_category_id(meta), - 'options' : '3' - } - - r = session.get(search_url, params=params) - await asyncio.sleep(0.5) - soup = BeautifulSoup(r.text, 'html.parser') - find = soup.find_all('a', href=True) - for each in find: - if each['href'].startswith('details.php?id='): - dupes.append(each.text) - - return dupes - - - async def validate_credentials(self, meta): - cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/HDT.txt") - vcookie = await self.validate_cookies(meta, cookiefile) - if vcookie != True: - console.print('[red]Failed to validate cookies. Please confirm that the site is up or export a fresh cookie file from the site') - return False - return True - - - async def validate_cookies(self, meta, cookiefile): - common = COMMON(config=self.config) - url = "https://hd-torrents.org/index.php" - cookiefile = f"{meta['base_dir']}/data/cookies/HDT.txt" - if os.path.exists(cookiefile): - with requests.Session() as session: - session.cookies.update(await common.parseCookieFile(cookiefile)) - res = session.get(url=url) - if meta['debug']: - console.print('[cyan]Cookies:') - console.print(session.cookies.get_dict()) - console.print(res.url) - if res.text.find("Logout") != -1: - return True - else: - return False + data['HDR10'] = 'true' + if "DV" in meta.get('hdr', ''): + data['DolbyVision'] = 'true' + + # IMDB + if int(meta.get('imdb_id')) != 0: + data['infosite'] = f"https://www.imdb.com/title/{meta.get('imdb_info', {}).get('imdbID', '')}/" + + # Full Season Pack + if int(meta.get('tv_pack', '0')) != 0: + data['season'] = 'true' else: - return False - - - - """ - Old login method, disabled because of site's DDOS protection. Better to use exported cookies. - - - async def login(self, cookiefile): - with requests.Session() as session: - url = "https://hd-torrents.org/login.php" - csrfToken = await self.get_csrfToken(session, url) - data = { - 'csrfToken' : csrfToken, - 'uid' : self.username, - 'pwd' : self.password, - 'submit' : 'Confirm' - } - response = session.post('https://hd-torrents.org/login.php', data=data) - await asyncio.sleep(0.5) - index = 'https://hd-torrents.org/index.php' - response = session.get(index) - if response.text.find("Logout") != -1: - console.print('[green]Successfully logged into HDT') - with open(cookiefile, 'wb') as cf: - pickle.dump(session.cookies, cf) - else: - console.print('[bold red]Something went wrong while trying to log into HDT. Make sure your username and password are correct') - await asyncio.sleep(1) - console.print(response.url) - return - """ - - - async def get_csrfToken(self, session, url): - r = session.get(url) - await asyncio.sleep(0.5) - soup = BeautifulSoup(r.text, 'html.parser') - csrfToken = soup.find('input', {'name' : 'csrfToken'}).get('value') - return csrfToken - - async def edit_desc(self, meta): - # base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', newline='') as descfile: - if meta['is_disc'] != 'BDMV': - # Beautify MediaInfo for HDT using custom template - video = meta['filelist'][0] - mi_template = os.path.abspath(f"{meta['base_dir']}/data/templates/MEDIAINFO.txt") - if os.path.exists(mi_template): - media_info = MediaInfo.parse(video, output="STRING", full=False, mediainfo_options={"inform" : f"file://{mi_template}"}) - descfile.write(f"""[left][font=consolas]\n{media_info}\n[/font][/left]\n""") + data['season'] = 'false' + + # Anonymous check + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): + data['anonymous'] = 'false' + else: + data['anonymous'] = 'true' + + return data + + async def upload(self, meta, disctype): + await self.common.edit_torrent(meta, self.tracker, self.source_flag, announce_url='https://hdts-announce.ru/announce.php') + data = await self.get_data(meta) + status_message = '' + + if not meta.get('debug', False): + torrent_id = '' + upload_url = f"{self.base_url}/upload.php" + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as torrent_file: + files = {'torrent': ('torrent.torrent', torrent_file, 'application/x-bittorrent')} + + response = await self.session.post(url=upload_url, data=data, files=files) + + if 'Upload successful!' in response.text: + status_message = "Torrent uploaded successfully." + + # Find the torrent id + match = re.search(r'download\.php\?id=([^&]+)', response.text) + if match: + torrent_id = match.group(1) + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + else: - console.print("[bold red]Couldn't find the MediaInfo template") - console.print("[green]Using normal MediaInfo for the description.") - - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8') as MI: - descfile.write(f"""[left][font=consolas]\n{MI.read()}\n[/font][/left]\n\n""") - else: - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') as BD_SUMMARY: - descfile.write(f"""[left][font=consolas]\n{BD_SUMMARY.read()}\n[/font][/left]\n\n""") - - # Add Screenshots - images = meta['image_list'] - if len(images) > 0: - for each in range(min(2, len(images))): - img_url = images[each]['img_url'] - raw_url = images[each]['raw_url'] - descfile.write(f' ') - - descfile.close() + status_message = 'data error - The upload appears to have failed. It may have uploaded, go check.' + + response_save_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(response_save_path, 'w', encoding='utf-8') as f: + f.write(response.text) + console.print(f'Upload failed, HTML response was saved to: {response_save_path}') + + await self.common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.announce_url, self.torrent_url + torrent_id) + + else: + console.print(data) + status_message = 'Debug mode enabled, not uploading.' + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/HHD.py b/src/trackers/HHD.py new file mode 100644 index 000000000..1da471df0 --- /dev/null +++ b/src/trackers/HHD.py @@ -0,0 +1,53 @@ +# -*- coding: utf-8 -*- +# import discord +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class HHD(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='HHD') + self.config = config + self.common = COMMON(config) + self.tracker = 'HHD' + self.source_flag = 'HHD' + self.base_url = 'https://homiehelpdesk.net' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + 'aXXo', 'BONE', 'BRrip', 'CM8', 'CrEwSaDe', 'CTFOH', 'dAV1nci', 'd3g', + 'DNL', 'FaNGDiNG0', 'GalaxyTV', 'HD2DVD', 'HDTime', 'iHYTECH', 'ION10', + 'iPlanet', 'KiNGDOM', 'LAMA', 'MeGusta', 'mHD', 'mSD', 'NaNi', 'NhaNc3', + 'nHD', 'nikt0', 'nSD', 'OFT', 'PRODJi', 'RARBG', 'Rifftrax', 'SANTi', + 'SasukeducK', 'ShAaNiG', 'Sicario', 'STUTTERSHIT', 'TGALAXY', 'TORRENTGALAXY', + 'TSP', 'TSPxL', 'ViSION', 'VXT', 'WAF', 'WKS', 'x0r', 'YAWNiX', 'YIFY', 'YTS', 'PSA', ['EVO', 'WEB-DL only'] + ] + pass + + async def get_resolution_id(self, meta, mapping_only=False, reverse=False, resolution=None): + resolution_id = { + '4320p': '1', + '2160p': '2', + '1440p': '3', + '1080p': '3', + '1080i': '4', + '720p': '5', + '576p': '6', + '576i': '7', + '480p': '8', + '480i': '9', + 'Other': '10' + } + if mapping_only: + return resolution_id + elif reverse: + return {v: k for k, v in resolution_id.items()} + elif resolution is not None: + return {'resolution_id': resolution_id.get(resolution, '10')} + else: + meta_resolution = meta.get('resolution', '') + resolved_id = resolution_id.get(meta_resolution, '10') + return {'resolution_id': resolved_id} diff --git a/src/trackers/HP.py b/src/trackers/HP.py deleted file mode 100644 index 250e9e851..000000000 --- a/src/trackers/HP.py +++ /dev/null @@ -1,182 +0,0 @@ -# -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os -import platform - -from src.trackers.COMMON import COMMON -from src.console import console - -class HP(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - - ############################################################### - ######## EDIT ME ######## - ############################################################### - def __init__(self, config): - self.config = config - self.tracker = 'HP' - self.source_flag = 'Hidden-Palace' - self.upload_url = 'https://hidden-palace.net/api/torrents/upload' - self.search_url = 'https://hidden-palace.net/api/torrents/filter' - self.signature = None - self.banned_groups = [""] - pass - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id - - async def get_type_id(self, type): - type_id = { - 'DISC': '1', - 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', - 'HDTV': '6', - 'ENCODE': '3' - }.get(type, '0') - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : meta['name'], - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = params['name'] + f"{meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + meta['edition'] - - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes \ No newline at end of file diff --git a/src/trackers/HUNO.py b/src/trackers/HUNO.py index 0bd8c746d..aaf0a18b9 100644 --- a/src/trackers/HUNO.py +++ b/src/trackers/HUNO.py @@ -1,289 +1,327 @@ # -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -from difflib import SequenceMatcher -import distutils.util +import aiofiles import os import re -import platform - from src.trackers.COMMON import COMMON from src.console import console +from src.rehostimages import check_hosts +from src.languages import parsed_mediainfo, process_desc_language +from src.trackers.UNIT3D import UNIT3D + -class HUNO(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ +class HUNO(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='HUNO') self.config = config + self.common = COMMON(config) self.tracker = 'HUNO' self.source_flag = 'HUNO' - self.search_url = 'https://hawke.uno/api/torrents/filter' - self.upload_url = 'https://hawke.uno/api/torrents/upload' - self.signature = "\n[center][url=https://github.com/L4GSP1KE/Upload-Assistant]Created by HUNO's Upload Assistant[/url][/center]" - self.banned_groups = [""] + self.base_url = 'https://hawke.uno' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + '4K4U', 'Bearfish', 'BiTOR', 'BONE', 'D3FiL3R', 'd3g', 'DTR', 'ELiTE', + 'EVO', 'eztv', 'EzzRips', 'FGT', 'HashMiner', 'HETeam', 'HEVCBay', 'HiQVE', + 'HR-DR', 'iFT', 'ION265', 'iVy', 'JATT', 'Joy', 'LAMA', 'm3th', 'MeGusta', + 'MRN', 'Musafirboy', 'OEPlus', 'Pahe.in', 'PHOCiS', 'PSA', 'RARBG', 'RMTeam', + 'ShieldBearer', 'SiQ', 'TBD', 'Telly', 'TSP', 'VXT', 'WKS', 'YAWNiX', 'YIFY', 'YTS' + ] pass - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta) - resolution_id = await self.get_res_id(meta['resolution']) - if meta['anon'] == 0 and bool(distutils.util.strtobool(self.config['TRACKERS']['HUNO'].get('anon', "False"))) == False: - anon = 0 + async def get_additional_checks(self, meta): + should_continue = True + + if await self.get_audio(meta) == "SKIPPED": + console.print(f'{self.tracker}: No audio languages were found, the upload cannot continue.') + should_continue = False + + if meta['video_codec'] != "HEVC" and meta['type'] in {"ENCODE", "WEBRIP", "DVDRIP", "HDTV"}: + if not meta['unattended']: + console.print('[bold red]Only x265/HEVC encodes are allowed at HUNO') + should_continue = False + + if not meta['is_disc'] and meta['type'] in ['ENCODE', 'WEBRIP', 'DVDRIP', 'HDTV']: + parsed_info = await parsed_mediainfo(meta) + for video_track in parsed_info.get('video', []): + encoding_settings = video_track.get('encoding_settings') + if not encoding_settings: + if not meta['unattended']: + console.print("No encoding settings found in MEDIAINFO for HUNO") + should_continue = False + break + + if encoding_settings: + crf_match = re.search(r'crf[ =:]+([\d.]+)', encoding_settings, re.IGNORECASE) + if crf_match: + crf_value = float(crf_match.group(1)) + if crf_value > 22: + if not meta['unattended']: + console.print(f"CRF value too high: {crf_value} for HUNO") + should_continue = False + break + else: + bit_rate = video_track.get('bit_rate') + if bit_rate and "Animation" not in meta.get('genre', ""): + bit_rate_num = None + # Match number and unit (e.g., 42.4 Mb/s, 42400 kb/s, etc.) + match = re.search(r'([\d.]+)\s*([kM]?b/s)', bit_rate.replace(',', '').replace(' ', ''), re.IGNORECASE) + if match: + value = float(match.group(1)) + unit = match.group(2).lower() + if unit == 'mb/s': + bit_rate_num = int(value * 1000) + elif unit == 'kb/s': + bit_rate_num = int(value) + else: + bit_rate_num = int(value) + if bit_rate_num is not None and bit_rate_num < 3000: + if not meta['unattended']: + console.print(f"Video bitrate too low: {bit_rate_num} kbps for HUNO") + should_continue = False + + return should_continue + + async def get_stream(self, meta): + return {'stream': await self.is_plex_friendly(meta)} + + async def get_description(self, meta): + url_host_mapping = { + "ibb.co": "imgbb", + "ptpimg.me": "ptpimg", + "pixhost.to": "pixhost", + "imgbox.com": "imgbox", + "imagebam.com": "bam", + } + approved_image_hosts = ['ptpimg', 'imgbox', 'imgbb', 'pixhost', 'bam'] + await check_hosts(meta, self.tracker, url_host_mapping=url_host_mapping, img_host_index=1, approved_image_hosts=approved_image_hosts) + if 'HUNO_images_key' in meta: + image_list = meta['HUNO_images_key'] else: - anon = 1 + image_list = meta['image_list'] + await self.common.unit3d_edit_desc(meta, self.tracker, self.signature, image_list=image_list) + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8') as f: + desc = await f.read() - # adding logic to check if its an encode or webrip and not HEVC as only HEVC encodes and webrips are allowed - if meta['video_codec'] != "HEVC" and (meta['type'] == "ENCODE" or meta['type'] == "WEBRIP"): - console.print(f'[bold red]Only x265/HEVC encodes are allowed') - return + return {'description': desc} - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() + async def get_mediainfo(self, meta): + if meta['bdinfo'] is not None: + mediainfo = await self.common.get_bdmv_mediainfo(meta, remove=['File size', 'Overall bit rate']) else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[HUNO]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[HUNO]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : await self.get_name(meta), - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : await self.is_plex_friendly(meta), - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'season_pack': meta.get('tv_pack', 0), - # 'featured' : 0, - # 'free' : 0, - # 'double_up' : 0, - # 'sticky' : 0, - } + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8') as f: + mediainfo = await f.read() - tracker_config = self.config['TRACKERS'][self.tracker] + return {'mediainfo': mediainfo} - if 'internal' in tracker_config: - if tracker_config['internal'] and meta['tag'] and meta['tag'][1:] in tracker_config.get('internal_groups', []): - data['internal'] = 1 - else: - data['internal'] = 0 + async def get_featured(self, meta): + return {} - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token': tracker_config['api_key'].strip() - } + async def get_free(self, meta): + if meta.get('freeleech', 0) != 0: + free = meta.get('freeleech', 0) + return {'free': free} + return {} - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - # adding torrent link to comment of torrent file - t_id = response.json()['data'].split(".")[1].split("/")[3] - await common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.config['TRACKERS'][self.tracker].get('announce_url'), "https://hawke.uno/torrents/" + t_id) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() + async def get_doubleup(self, meta): + return {} + + async def get_sticky(self, meta): + return {} + + async def get_season_number(self, meta): + if meta.get('category') == 'TV' and meta.get('tv_pack') == 1: + return {'season_pack': 1} + return {} + + async def get_episode_number(self, meta): + return {} - def get_audio(self, meta): + async def get_personal_release(self, meta): + return {} + + async def get_internal(self, meta): + if self.config['TRACKERS'][self.tracker].get('internal', False) is True: + if meta['tag'] != '' and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): + return {'internal': 1} + return {} + + async def get_additional_files(self, meta): + return {} + + async def get_audio(self, meta): channels = meta.get('channels', "") codec = meta.get('audio', "").replace("DD+", "DDP").replace("EX", "").replace("Dual-Audio", "").replace(channels, "") - dual = "Dual-Audio" in meta.get('audio', "") - language = "" + languages = "" + + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + if meta.get('audio_languages'): + languages = meta['audio_languages'] + languages = set(languages) + if len(languages) > 2: + languages = "Multi" + elif len(languages) > 1: + languages = "Dual" + else: + languages = list(languages)[0] - if dual: - language = "DUAL" - elif 'mediainfo' in meta: - language = next(x for x in meta["mediainfo"]["media"]["track"] if x["@type"] == "Audio").get('Language_String', "English") - language = re.sub(r'\(.+\)', '', language) + if "zxx" in languages: + languages = "NONE" + elif not languages: + languages = "SKIPPED" + else: + languages = "SKIPPED" + + return f'{codec} {channels} {languages}' - return f'{codec} {channels} {language}' - def get_basename(self, meta): path = next(iter(meta['filelist']), meta['path']) return os.path.basename(path) async def get_name(self, meta): - # Copied from Prep.get_name() then modified to match HUNO's naming convention. - # It was much easier to build the name from scratch than to alter the existing name. + distributor_name = meta.get('distributor', "") + region = meta.get('region', '') basename = self.get_basename(meta) - type = meta.get('type', "") - title = meta.get('title',"") - alt_title = meta.get('aka', "") + if meta.get('hardcoded-subs'): + hc = "Hardsubbed" + else: + hc = "" + type = meta.get('type', "").upper() + title = meta.get('title', "") year = meta.get('year', "") resolution = meta.get('resolution', "") - audio = self.get_audio(meta) + audio = await self.get_audio(meta) service = meta.get('service', "") season = meta.get('season', "") + if meta.get('tvdb_season_number', ""): + season_int = meta.get('tvdb_season_number') + season = f"S{str(season_int).zfill(2)}" episode = meta.get('episode', "") + if meta.get('tvdb_episode_number', ""): + episode_int = meta.get('tvdb_episode_number') + episode = f"E{str(episode_int).zfill(2)}" repack = meta.get('repack', "") if repack.strip(): repack = f"[{repack}]" three_d = meta.get('3D', "") tag = meta.get('tag', "").replace("-", "- ") - if tag == "": + tag_lower = tag.lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + tag = re.sub(f"- {invalid_tag}", "", tag, flags=re.IGNORECASE) tag = "- NOGRP" - source = meta.get('source', "") - uhd = meta.get('uhd', "") + source = meta.get('source', "").replace("Blu-ray", "BluRay") + if source == "BluRay" and "2160" in resolution: + source = "UHD BluRay" + if any(x in source.lower() for x in ["pal", "ntsc"]) and type == "ENCODE": + source = "DVD" hdr = meta.get('hdr', "") if not hdr.strip(): hdr = "SDR" - distributor = meta.get('distributor', "") + if distributor_name and distributor_name.upper() in ['CRITERION', 'BFI', 'SHOUT FACTORY']: + distributor = distributor_name.title() + else: + if meta.get('distributor', "") and meta.get('distributor').upper() in ['CRITERION', 'BFI', 'SHOUT FACTORY']: + distributor = meta.get('distributor').title() + else: + distributor = "" video_codec = meta.get('video_codec', "") video_encode = meta.get('video_encode', "").replace(".", "") - if 'x265' in basename: + if 'x265' in basename and not meta.get('type') == "WEBDL": video_encode = video_encode.replace('H', 'x') - region = meta.get('region', "") dvd_size = meta.get('dvd_size', "") edition = meta.get('edition', "") - hybrid = "Hybrid" if "HYBRID" in basename.upper() else "" - search_year = meta.get('search_year', "") - if not str(search_year).strip(): - search_year = year + hybrid = 'Hybrid' if meta.get('webdv', "") else '' scale = "DS4K" if "DS4K" in basename.upper() else "RM4K" if "RM4K" in basename.upper() else "" + hfr = "HFR" if meta.get('hfr', '') else "" - #YAY NAMING FUN - if meta['category'] == "MOVIE": #MOVIE SPECIFIC - if type == "DISC": #Disk + # YAY NAMING FUN + if meta['category'] == "MOVIE": # MOVIE SPECIFIC + if type == "DISC": # Disk if meta['is_disc'] == 'BDMV': - name = f"{title} ({year}) {three_d} {edition} ({resolution} {region} {uhd} {source} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" + name = f"{title} ({year}) {distributor} {edition} {hc} ({resolution} {region} {three_d} {source} {hybrid} {video_codec} {hfr} {hdr} {audio} {tag}) {repack}" elif meta['is_disc'] == 'DVD': - name = f"{title} ({year}) {edition} ({resolution} {dvd_size} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" + name = f"{title} ({year}) {distributor} {edition} {hc} ({resolution} {source} {dvd_size} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" elif meta['is_disc'] == 'HDDVD': - name = f"{title} ({year}) {edition} ({resolution} {source} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" - elif type == "REMUX" and source == "BluRay": #BluRay Remux - name = f"{title} ({year}) {three_d} {edition} ({resolution} {uhd} {source} {hybrid} REMUX {video_codec} {hdr} {audio} {tag}) {repack}" - elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD"): #DVD Remux - name = f"{title} ({year}) {edition} (DVD {hybrid} REMUX {video_codec} {hdr} {audio} {tag}) {repack}" - elif type == "ENCODE": #Encode - name = f"{title} ({year}) {edition} ({resolution} {scale} {uhd} {source} {hybrid} {video_encode} {hdr} {audio} {tag}) {repack}" - elif type in ("WEBDL", "WEBRIP"): #WEB - name = f"{title} ({year}) {edition} ({resolution} {scale} {uhd} {service} WEB-DL {hybrid} {video_encode} {hdr} {audio} {tag}) {repack}" - elif type == "HDTV": #HDTV - name = f"{title} ({year}) {edition} ({resolution} HDTV {hybrid} {video_encode} {audio} {tag}) {repack}" - elif meta['category'] == "TV": #TV SPECIFIC - if type == "DISC": #Disk + name = f"{title} ({year}) {distributor} {edition} {hc} ({resolution} {source} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" + elif type == "REMUX" and source.endswith("BluRay"): # BluRay Remux + name = f"{title} ({year}) {edition} ({resolution} {three_d} {source} {hybrid} REMUX {video_codec} {hfr} {hdr} {audio} {tag}) {repack}" + elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD"): # DVD Remux + name = f"{title} ({year}) {edition} {hc} ({resolution} {source} {hybrid} REMUX {video_codec} {hdr} {audio} {tag}) {repack}" + elif type == "ENCODE": # Encode + name = f"{title} ({year}) {edition} {hc} ({resolution} {scale} {source} {hybrid} {video_encode} {hfr} {hdr} {audio} {tag}) {repack}" + elif type in ("WEBDL", "WEBRIP"): # WEB + name = f"{title} ({year}) {edition} {hc} ({resolution} {scale} {service} WEB-DL {hybrid} {video_encode} {hfr} {hdr} {audio} {tag}) {repack}" + elif type == "HDTV": # HDTV + name = f"{title} ({year}) {edition} {hc} ({resolution} HDTV {hybrid} {video_encode} {audio} {tag}) {repack}" + elif type == "DVDRIP": + name = f"{title} ({year}) {edition} {hc} ({resolution} {source} {video_encode} {hdr} {audio} {tag}) {repack}" + elif meta['category'] == "TV": # TV SPECIFIC + if type == "DISC": # Disk if meta['is_disc'] == 'BDMV': - name = f"{title} ({search_year}) {season}{episode} {three_d} {edition} ({resolution} {region} {uhd} {source} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" + name = f"{title} ({year}) {season}{episode} {distributor} {edition} {hc} ({resolution} {region} {three_d} {source} {hybrid} {video_codec} {hfr} {hdr} {audio} {tag}) {repack}" if meta['is_disc'] == 'DVD': - name = f"{title} ({search_year}) {season}{episode} {edition} ({resolution} {dvd_size} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" + name = f"{title} ({year}) {season}{episode} {distributor} {edition} {hc} ({resolution} {source} {dvd_size} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" elif meta['is_disc'] == 'HDDVD': - name = f"{title} ({search_year}) {season}{episode} {edition} ({resolution} {source} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" - elif type == "REMUX" and source == "BluRay": #BluRay Remux - name = f"{title} ({search_year}) {season}{episode} {three_d} {edition} ({resolution} {uhd} {source} {hybrid} REMUX {video_codec} {hdr} {audio} {tag}) {repack}" #SOURCE - elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD"): #DVD Remux - name = f"{title} ({search_year}) {season}{episode} {edition} ({resolution} DVD {hybrid} REMUX {video_codec} {hdr} {audio} {tag}) {repack}" #SOURCE - elif type == "ENCODE": #Encode - name = f"{title} ({search_year}) {season}{episode} {edition} ({resolution} {scale} {uhd} {source} {hybrid} {video_encode} {hdr} {audio} {tag}) {repack}" #SOURCE - elif type in ("WEBDL", "WEBRIP"): #WEB - name = f"{title} ({search_year}) {season}{episode} {edition} ({resolution} {scale} {uhd} {service} WEB-DL {hybrid} {video_encode} {hdr} {audio} {tag}) {repack}" - elif type == "HDTV": #HDTV - name = f"{title} ({search_year}) {season}{episode} {edition} ({resolution} HDTV {hybrid} {video_encode} {audio} {tag}) {repack}" - - return ' '.join(name.split()).replace(": ", " - ") - - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id - + name = f"{title} ({year}) {season}{episode} {edition} ({resolution} {source} {hybrid} {video_codec} {hdr} {audio} {tag}) {repack}" + elif type == "REMUX" and source == "BluRay": # BluRay Remux + name = f"{title} ({year}) {season}{episode} {edition} ({resolution} {three_d} {source} {hybrid} REMUX {video_codec} {hfr} {hdr} {audio} {tag}) {repack}" # SOURCE + elif type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD"): # DVD Remux + name = f"{title} ({year}) {season}{episode} {edition} ({resolution} {source} {hybrid} REMUX {video_codec} {hdr} {audio} {tag}) {repack}" # SOURCE + elif type == "ENCODE": # Encode + name = f"{title} ({year}) {season}{episode} {edition} ({resolution} {scale} {source} {hybrid} {video_encode} {hfr} {hdr} {audio} {tag}) {repack}" # SOURCE + elif type in ("WEBDL", "WEBRIP"): # WEB + name = f"{title} ({year}) {season}{episode} {edition} ({resolution} {scale} {service} WEB-DL {hybrid} {video_encode} {hfr} {hdr} {audio} {tag}) {repack}" + elif type == "HDTV": # HDTV + name = f"{title} ({year}) {season}{episode} {edition} ({resolution} HDTV {hybrid} {video_encode} {audio} {tag}) {repack}" + + name = ' '.join(name.split()).replace(": ", " - ") + name = re.sub(r'\s{2,}', ' ', name) + return {'name': name} async def get_type_id(self, meta): - basename = self.get_basename(meta) - type = meta['type'] - - if type == 'REMUX': - return '2' - elif type in ('WEBDL', 'WEBRIP'): - return '15' if 'x265' in basename else '3' - elif type in ('ENCODE', 'HDTV'): - return '15' - elif type == 'DISC': - return '1' + type_value = (meta.get('type') or '').lower() + video_encode = (meta.get('video_encode') or '').lower() + + if type_value == 'remux': + type_id = '2' + elif type_value in ('webdl', 'webrip'): + type_id = '15' if 'x265' in video_encode else '3' + elif type_value in ('encode', 'hdtv'): + type_id = '15' + elif type_value == 'disc': + type_id = '1' else: - return '0' + type_id = '0' + return {'type_id': type_id} - async def get_res_id(self, resolution): + async def get_resolution_id(self, meta): resolution_id = { - 'Other':'10', + 'Other': '10', '4320p': '1', '2160p': '2', '1080p': '3', - '1080i':'4', + '1080i': '4', '720p': '5', '576p': '6', '576i': '7', + '540p': '11', + # no mapping for 540i + '540i': '11', '480p': '8', '480i': '9' - }.get(resolution, '10') - return resolution_id - + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} async def is_plex_friendly(self, meta): lossy_audio_codecs = ["AAC", "DD", "DD+", "OPUS"] - if any(l in meta["audio"] for l in lossy_audio_codecs): + if any(codec in meta["audio"] for codec in lossy_audio_codecs): return 1 return 0 - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - - params = { - 'api_token' : self.config['TRACKERS']['HUNO']['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = f"{meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] + meta['edition'] - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes diff --git a/src/trackers/ITT.py b/src/trackers/ITT.py new file mode 100644 index 000000000..7be75a3c5 --- /dev/null +++ b/src/trackers/ITT.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class ITT(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='ITT') + self.config = config + self.common = COMMON(config) + self.tracker = 'ITT' + self.source_flag = 'ItaTorrents' + self.base_url = 'https://itatorrents.xyz' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_type_id(self, meta): + type_id = { + 'DISC': '1', + 'REMUX': '2', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6', + 'ENCODE': '3', + 'DLMux': '27', + 'BDMux': '29', + 'WEBMux': '26', + 'DVDMux': '39', + 'BDRip': '25', + 'DVDRip': '24', + 'Cinema-MD': '14', + }.get(meta['type'], '0') + return {'type_id': type_id} diff --git a/src/trackers/JPTV.py b/src/trackers/JPTV.py deleted file mode 100644 index 354b1be1a..000000000 --- a/src/trackers/JPTV.py +++ /dev/null @@ -1,220 +0,0 @@ -# -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os -import platform - -from src.trackers.COMMON import COMMON -from src.console import console - - -class JPTV(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - - ############################################################### - ######## EDIT ME ######## - ############################################################### - - # ALSO EDIT CLASS NAME ABOVE - - def __init__(self, config): - self.config = config - self.tracker = 'JPTV' - self.source_flag = 'jptv.club' - self.upload_url = 'https://jptv.club/api/torrents/upload' - self.search_url = 'https://jptv.club/api/torrents/filter' - self.signature = None - self.banned_groups = [""] - pass - - async def get_cat_id(self, meta): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(meta['category'], '0') - if meta['anime']: - category_id = { - 'MOVIE': '7', - 'TV': '9', - }.get(meta['category'], '0') - return category_id - - async def get_type_id(self, type): - type_id = { - 'DISC': '16', - 'REMUX': '18', - 'WEBDL': '4', - 'WEBRIP': '5', - 'HDTV': '6', - 'ENCODE': '3' - }.get(type, '0') - # DVDISO 17 - # DVDRIP 1 - # TS (Raw) 14 - # Re-encode 15 - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - jptv_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - - if meta['bdinfo'] != None: - mi_dump = "" - for each in meta['discs']: - mi_dump = mi_dump + each['summary'].strip() + "\n\n" - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - # bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : jptv_name, - 'description' : desc, - 'mediainfo' : mi_dump, - # 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdb' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta.get('edition', "") != "": - params['name'] = params['name'] + f" {meta['edition']}" - if meta['debug']: - console.log("[cyan]Dupe Search Parameters") - console.log(params) - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes - - - async def edit_name(self, meta): - name = meta.get('name') - aka = meta.get('aka') - original_title = meta.get('original_title') - year = str(meta.get('year')) - audio = meta.get('audio') - source = meta.get('source') - is_disc = meta.get('is_disc') - if aka != '': - # ugly fix to remove the extra space in the title - aka = aka + ' ' - name = name.replace(aka, f'{original_title} {chr(int("202A", 16))}') - elif aka == '': - if meta.get('title') != original_title: - # name = f'{name[:name.find(year)]}/ {original_title} {chr(int("202A", 16))}{name[name.find(year):]}' - name = name.replace(meta['title'], f"{original_title} {chr(int('202A', 16))} {meta['title']}") - if 'AAC' in audio: - name = name.replace(audio.strip().replace(" ", " "), audio.replace(" ", "")) - name = name.replace("DD+ ", "DD+") - - return name \ No newline at end of file diff --git a/src/trackers/LCD.py b/src/trackers/LCD.py index 5c3f14309..82d902ad6 100644 --- a/src/trackers/LCD.py +++ b/src/trackers/LCD.py @@ -1,191 +1,127 @@ # -*- coding: utf-8 -*- # import discord -import asyncio -import requests -import distutils.util -import os -import platform - +import aiofiles +import re from src.trackers.COMMON import COMMON -from src.console import console - +from src.trackers.UNIT3D import UNIT3D -class LCD(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ +class LCD(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='LCD') self.config = config + self.common = COMMON(config) self.tracker = 'LCD' self.source_flag = 'LOCADORA' - self.search_url = 'https://locadora.cc/api/torrents/filter' - self.torrent_url = 'https://locadora.cc/api/torrents/' - self.upload_url = 'https://locadora.cc/api/torrents/upload' - self.signature = f"\n[center]Criado usando L4G's Upload Assistant[/center]" - self.banned_groups = [""] + self.base_url = 'https://locadora.cc' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] pass - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - cat_id = await self.get_cat_id(meta['category'], meta.get('edition', ''), meta) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() + async def get_name(self, meta): + if meta.get('is_disc', '') == 'BDMV': + name = meta.get('name') + else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[LCD]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[LCD]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': ("placeholder.torrent", open_torrent, "application/x-bittorrent")} - data = { - 'name' : name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, + name = meta['uuid'] + + replacements = { + '.mkv': '', + '.mp4': '', + '.': ' ', + 'DDP2 0': 'DDP2.0', + 'DDP5 1': 'DDP5.1', + 'H 264': 'H.264', + 'H 265': 'H.265', + 'DD+7 1': 'DDP7.1', + 'AAC2 0': 'AAC2.0', + 'DD5 1': 'DD5.1', + 'DD2 0': 'DD2.0', + 'TrueHD 7 1': 'TrueHD 7.1', + 'TrueHD 5 1': 'TrueHD 5.1', + 'DTS-HD MA 7 1': 'DTS-HD MA 7.1', + 'DTS-HD MA 5 1': 'DTS-HD MA 5.1', + 'DTS-X 7 1': 'DTS-X 7.1', + 'DTS-X 5 1': 'DTS-X 5.1', + 'FLAC 2 0': 'FLAC 2.0', + 'FLAC 5 1': 'FLAC 5.1', + 'DD1 0': 'DD1.0', + 'DTS ES 5 1': 'DTS ES 5.1', + 'DTS5 1': 'DTS 5.1', + 'AAC1 0': 'AAC1.0', + 'DD+5 1': 'DDP5.1', + 'DD+2 0': 'DDP2.0', + 'DD+1 0': 'DDP1.0', } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() + for old, new in replacements.items(): + name = name.replace(old, new) + + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + name = re.sub(f"-{invalid_tag}", "", name, flags=re.IGNORECASE) + name = f'{name}-NoGroup' + return {'name': name} + async def get_region_id(self, meta): + if meta.get('region') == 'EUR': + return {} + region_id = await self.common.unit3d_region_ids(meta.get('region')) + if region_id != 0: + return {'region_id': region_id} + return {} - async def get_cat_id(self, category_name, edition, meta): + async def get_mediainfo(self, meta): + if meta['bdinfo'] is not None: + mediainfo = await self.common.get_bdmv_mediainfo(meta, remove=['File size', 'Overall bit rate']) + else: + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8') as f: + mediainfo = await f.read() + + return {'mediainfo': mediainfo} + + async def get_category_id(self, meta): category_id = { - 'MOVIE': '1', + 'MOVIE': '1', 'TV': '2', 'ANIMES': '6' - }.get(category_name, '0') - if meta['anime'] == True and category_id == '2': + }.get(meta['category'], '0') + if meta['anime'] is True and category_id == '2': category_id = '6' - return category_id + return {'category_id': category_id} - async def get_type_id(self, type): + async def get_type_id(self, meta): type_id = { - 'DISC': '1', + 'DISC': '1', 'REMUX': '2', 'ENCODE': '3', - 'WEBDL': '4', - 'WEBRIP': '5', + 'WEBDL': '4', + 'WEBRIP': '5', 'HDTV': '6' - }.get(type, '0') - return type_id + }.get(meta['type'], '0') + return {'type_id': type_id} - async def get_res_id(self, resolution): + async def get_resolution_id(self, meta): resolution_id = { -# '8640p':'10', - '4320p': '1', - '2160p': '2', -# '1440p' : '2', + # '8640p':'10', + '4320p': '1', + '2160p': '2', + # '1440p' : '2', '1080p': '3', - '1080i':'34', - '720p': '5', - '576p': '6', + '1080i': '34', + '720p': '5', + '576p': '6', '576i': '7', - '480p': '8', + '480p': '8', '480i': '9', 'Other': '10', - }.get(resolution, '10') - return resolution_id - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Buscando por duplicatas no tracker...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category'], meta.get('edition', ''), meta), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = params['name'] + f" {meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + f" {meta['edition']}" - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Não foi possivel buscar no tracker torrents duplicados. O tracker está offline ou sua api está incorreta') - await asyncio.sleep(5) - - return dupes - - async def edit_name(self, meta): - - - name = meta['uuid'].replace('.mkv','').replace('.mp4','').replace(".", " ").replace("DDP2 0","DDP2.0").replace("DDP5 1","DDP5.1").replace("H 264","H.264").replace("H 265","H.264").replace("DD+7 1","DD+7.1").replace("AAC2 0","AAC2.0").replace('DD5 1','DD5.1').replace('DD2 0','DD2.0').replace('TrueHD 7 1','TrueHD 7.1').replace('DTS-HD MA 7 1','DTS-HD MA 7.1').replace('-C A A','-C.A.A') - - return name + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} diff --git a/src/trackers/LDU.py b/src/trackers/LDU.py new file mode 100644 index 000000000..3da625e42 --- /dev/null +++ b/src/trackers/LDU.py @@ -0,0 +1,143 @@ +# -*- coding: utf-8 -*- +# import discord +import langcodes +from src.languages import has_english_language +from src.trackers.COMMON import COMMON +from src.console import console +from src.trackers.UNIT3D import UNIT3D + + +class LDU(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='LDU') + self.config = config + self.common = COMMON(config) + self.tracker = 'LDU' + self.source_flag = 'LDU' + self.base_url = 'https://theldu.to' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_category_id(self, meta): + genres = f"{meta.get('keywords', '')} {meta.get('genres', '')}" + sound_mixes = meta.get('imdb_info', {}).get('sound_mixes', []) + + category_id = { + 'MOVIE': '1', + 'TV': '2', + 'Anime': '8', + 'FANRES': '12', + 'MUSIC': '3', + }.get(meta['category'], '0') + + if 'hentai' in genres.lower(): + category_id = '10' + elif any(x in genres.lower() for x in ['xxx', 'erotic', 'porn', 'adult', 'orgy']): + if not await has_english_language(meta.get('subtitle_languages', [])): + category_id = '45' + else: + category_id = '6' + if meta['category'] == "MOVIE": + if meta.get('3d') or "3D" in meta.get('edition', ''): + category_id = '21' + elif any(x in meta.get('edition', '').lower() for x in ["fanedit", "fanres"]): + category_id = '12' + elif meta.get('anime', False) or meta.get('mal_id', 0) != 0: + category_id = '8' + elif (any('silent film' in mix.lower() for mix in sound_mixes if isinstance(mix, str)) or meta.get('silent', False)): + category_id = '18' + elif "musical" in genres.lower(): + category_id = '25' + elif any(x in genres.lower() for x in ['holiday', 'easter', 'christmas', 'halloween', 'thanksgiving']): + category_id = '24' + elif "documentary" in genres.lower(): + category_id = '17' + elif any(x in genres.lower() for x in ['stand-up', 'standup']): + category_id = '20' + elif "short film" in genres.lower() or int(meta.get('imdb_info', {}).get('runtime', 0)) < 5: + category_id = '19' + elif not await has_english_language(meta.get('audio_languages', [])) and not await has_english_language(meta.get('subtitle_languages', [])): + category_id = '22' + elif "dubbed" in meta.get('audio', '').lower(): + category_id = '27' + else: + category_id = '1' + elif meta['category'] == "TV": + if meta.get('anime', False) or meta.get('mal_id', 0) != 0: + category_id = '9' + elif "documentary" in genres.lower(): + category_id = '40' + elif not await has_english_language(meta.get('audio_languages', [])) and not await has_english_language(meta.get('subtitle_languages', [])): + category_id = '29' + elif meta.get('tv_pack', False): + category_id = '2' + elif "dubbed" in meta.get('audio', '').lower(): + category_id = '31' + else: + category_id = '41' + + return {'category_id': category_id} + + async def get_type_id(self, meta): + type_id = { + 'DISC': '1', + 'REMUX': '2', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6', + 'ENCODE': '3' + }.get(meta.get('type'), '0') + if any(x in meta.get('edition', '').lower() for x in ["fanedit", "fanres"]): + type_id = '16' + return {'type_id': type_id} + + async def get_name(self, meta): + ldu_name = meta['name'] + cat_id = (await self.get_category_id(meta))['category_id'] + non_eng = False + non_eng_audio = False + iso_audio = None + iso_subtitle = None + if meta.get('original_language') != "en": + non_eng = True + if meta.get('audio_languages'): + audio_language = meta['audio_languages'][0] + if audio_language: + try: + lang = langcodes.find(audio_language).to_alpha3() + iso_audio = lang.upper() + if not await has_english_language(audio_language): + non_eng_audio = True + except Exception as e: + console.print(f"[bold red]Error extracting audio language: {e}[/bold red]") + + if meta.get('no_subs', False): + iso_subtitle = "NoSubs" + else: + if meta.get('subtitle_languages'): + subtitle_language = meta['subtitle_languages'][0] + if subtitle_language: + try: + lang = langcodes.find(subtitle_language).to_alpha3() + iso_subtitle = f"Subs {lang.upper()}" + except Exception as e: + console.print(f"[bold red]Error extracting subtitle language: {e}[/bold red]") + + if cat_id == '18' and iso_subtitle: + ldu_name = f"{ldu_name} [{iso_subtitle}]" + + elif non_eng or non_eng_audio: + language_parts = [] + if iso_audio: + language_parts.append(f"[{iso_audio}]") + if iso_subtitle: + language_parts.append(f"[{iso_subtitle}]") + + if language_parts: + ldu_name = f"{ldu_name} {' '.join(language_parts)}" + + return {'name': ldu_name} diff --git a/src/trackers/LST.py b/src/trackers/LST.py index 21368bd39..2908927e6 100644 --- a/src/trackers/LST.py +++ b/src/trackers/LST.py @@ -1,196 +1,83 @@ # -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os -import platform - from src.trackers.COMMON import COMMON -from src.console import console - - -class LST(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ +from src.trackers.UNIT3D import UNIT3D - ############################################################### - ######## EDIT ME ######## - ############################################################### - - # ALSO EDIT CLASS NAME ABOVE +class LST(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='LST') self.config = config + self.common = COMMON(config) self.tracker = 'LST' self.source_flag = 'LST.GG' - self.upload_url = 'https://lst.gg/api/torrents/upload' - self.search_url = 'https://lst.gg/api/torrents/filter' - self.signature = f"\n[center]Created by L4G's Upload Assistant[/center]" - self.banned_groups = [""] + self.base_url = 'https://lst.gg' + self.banned_url = f'{self.base_url}/api/bannedReleaseGroups' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] pass - - async def get_cat_id(self, category_name, keywords, service): - category_id = { - 'MOVIE': '1', - 'TV': '2', - 'Anime': '6', - }.get(category_name, '0') - if category_name == 'TV' and 'anime' in keywords: - category_id = '6' - elif category_name == 'TV' and 'hentai' in service: - category_id = '8' - return category_id - async def get_type_id(self, type): + async def get_type_id(self, meta): type_id = { - 'DISC': '1', + 'DISC': '1', 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', + 'WEBDL': '4', + 'WEBRIP': '5', 'HDTV': '6', - 'ENCODE': '3' - }.get(type, '0') - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id + 'ENCODE': '3', + 'DVDRIP': '3' + }.get(meta['type'], '0') + return {'type_id': type_id} - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta['category'], meta.get('keywords', ''), meta.get('service', '')) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - if meta.get('service') == "hentai": - desc = "[center]" + "[img]" + str(meta['poster']) + "[/img][/center]" + f"\n[center]" + "https://www.themoviedb.org/tv/" + str(meta['tmdb']) + f"\nhttps://myanimelist.net/anime/" + str(meta['mal']) + "[/center]" + desc - - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} + async def get_additional_data(self, meta): data = { - 'name' : meta['name'], - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, + 'mod_queue_opt_in': await self.get_flag(meta, 'modq'), + 'draft_queue_opt_in': await self.get_flag(meta, 'draft'), } - - - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category'], meta.get('keywords', ''), meta.get('service', '')), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" + # Only add edition_id if we have a valid edition + edition_id = await self.get_edition(meta) + if edition_id is not None: + data['edition_id'] = edition_id + + return data + + async def get_edition(self, meta): + edition_mapping = { + 'Alternative Cut': 12, + 'Collector\'s Edition': 1, + 'Director\'s Cut': 2, + 'Extended Cut': 3, + 'Extended Uncut': 4, + 'Extended Unrated': 5, + 'Limited Edition': 6, + 'Special Edition': 7, + 'Theatrical Cut': 8, + 'Uncut': 9, + 'Unrated': 10, + 'X Cut': 11, + 'Other': 0 # Default value for "Other" } - if meta['category'] == 'TV': - params['name'] = params['name'] + f" {meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + f" {meta['edition']}" - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes + edition = meta.get('edition', '') + if edition in edition_mapping: + return edition_mapping[edition] + else: + return None + + async def get_name(self, meta): + lst_name = meta['name'] + resolution = meta.get('resolution') + video_encode = meta.get('video_encode') + name_type = meta.get('type', "") + + if name_type == "DVDRIP": + if meta.get('category') == "MOVIE": + lst_name = lst_name.replace(f"{meta['source']}{meta['video_encode']}", f"{resolution}", 1) + lst_name = lst_name.replace((meta['audio']), f"{meta['audio']}{video_encode}", 1) + else: + lst_name = lst_name.replace(f"{meta['source']}", f"{resolution}", 1) + lst_name = lst_name.replace(f"{meta['video_codec']}", f"{meta['audio']} {meta['video_codec']}", 1) + + return {'name': lst_name} diff --git a/src/trackers/LT.py b/src/trackers/LT.py index 2e06a0df2..8132a3d33 100644 --- a/src/trackers/LT.py +++ b/src/trackers/LT.py @@ -1,191 +1,76 @@ # -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os -import platform - from src.trackers.COMMON import COMMON -from src.console import console - - -class LT(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - - ############################################################### - ######## EDIT ME ######## - ############################################################### +from src.trackers.UNIT3D import UNIT3D - # ALSO EDIT CLASS NAME ABOVE +class LT(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='LT') self.config = config + self.common = COMMON(config) self.tracker = 'LT' self.source_flag = 'Lat-Team "Poder Latino"' - self.upload_url = 'https://lat-team.com/api/torrents/upload' - self.search_url = 'https://lat-team.com/api/torrents/filter' - self.signature = '' - self.banned_groups = [""] + self.base_url = 'https://lat-team.com' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] pass - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id - - async def get_type_id(self, type): - type_id = { - 'DISC': '1', - 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', - 'HDTV': '6', - 'ENCODE': '3' - }.get(type, '0') - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - async def edit_name(self, meta): - lt_name = meta['name'] - lt_name = lt_name.replace('Dubbed', '').replace('Dual-Audio', '') - return lt_name - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - lt_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : lt_name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = params['name'] + f" {meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + f" {meta['edition']}" - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes + async def get_category_id(self, meta): + category_id = { + 'MOVIE': '1', + 'TV': '2', + 'ANIME': '5', + 'TELENOVELAS': '8', + 'Asiáticas & Turcas': '20', + }.get(meta['category'], '0') + # if is anime + if meta['anime'] is True and category_id == '2': + category_id = '5' + # elif is telenovela + elif category_id == '2' and ("telenovela" in meta['keywords'] or "telenovela" in meta['overview']): + category_id = '8' + # if is TURCAS o Asiáticas + elif meta["original_language"] in ['ja', 'ko', 'tr'] and category_id == '2' and 'Drama' in meta['genres']: + category_id = '20' + return {'category_id': category_id} + + async def get_name(self, meta): + lt_name = meta['name'].replace('Dual-Audio', '').replace('Dubbed', '').replace(meta['aka'], '').replace(' ', ' ').strip() + if meta['type'] != 'DISC': # DISC don't have mediainfo + # Check if is HYBRID (Copied from BLU.py) + if 'hybrid' in meta.get('uuid').lower(): + if "repack" in meta.get('uuid').lower(): + lt_name = lt_name.replace('REPACK', 'Hybrid REPACK') + else: + lt_name = lt_name.replace(meta['resolution'], f"Hybrid {meta['resolution']}") + # Check if original language is "es" if true replace title for AKA if available + if meta.get('original_language') == 'es' and meta.get('aka') != "": + lt_name = lt_name.replace(meta.get('title'), meta.get('aka').replace('AKA', '')).strip() + # Check if audio Spanish exists + audios = [ + audio for audio in meta['mediainfo']['media']['track'][2:] + if audio.get('@type') == 'Audio' + and isinstance(audio.get('Language'), str) + and audio.get('Language').lower() in {'es-419', 'es', 'es-mx', 'es-ar', 'es-cl', 'es-ve', 'es-bo', 'es-co', + 'es-cr', 'es-do', 'es-ec', 'es-sv', 'es-gt', 'es-hn', 'es-ni', 'es-pa', + 'es-py', 'es-pe', 'es-pr', 'es-uy'} + and "commentary" not in str(audio.get('Title', '')).lower() + ] + if len(audios) > 0: # If there is at least 1 audio spanish + lt_name = lt_name + # if not audio Spanish exists, add "[SUBS]" + elif not meta.get('tag'): + lt_name = lt_name + " [SUBS]" + else: + lt_name = lt_name.replace(meta['tag'], f" [SUBS]{meta['tag']}") + + return {'name': lt_name} + + async def get_distributor_ids(self, meta): + return {} + + async def get_region_id(self, meta): + return {} diff --git a/src/trackers/MTV.py b/src/trackers/MTV.py index d4de506d1..f573dcda6 100644 --- a/src/trackers/MTV.py +++ b/src/trackers/MTV.py @@ -1,16 +1,23 @@ -import requests +import aiofiles import asyncio from src.console import console import traceback from torf import Torrent -import xml.etree.ElementTree +import httpx +import xml.etree.ElementTree as ET import os import cli_ui import pickle import re -import distutils.util +import aiofiles.os +import pyotp from pathlib import Path from src.trackers.COMMON import COMMON +from datetime import datetime +from src.torrentcreate import CustomTorrent, torf_cb, create_torrent +from src.rehostimages import check_hosts +from data.config import config + class MTV(): """ @@ -29,74 +36,115 @@ def __init__(self, config): self.forum_link = 'https://www.morethantv.me/wiki.php?action=article&id=73' self.search_url = 'https://www.morethantv.me/api/torznab' self.banned_groups = [ - '3LTON', 'mRS', 'CM8', 'BRrip', 'Leffe', 'aXXo', 'FRDS', 'XS', 'KiNGDOM', 'WAF', 'nHD', - 'h65', 'CrEwSaDe', 'TM', 'ViSiON', 'x0r', 'PandaRG', 'HD2DVD', 'iPlanet', 'JIVE', 'ELiTE', - 'nikt0', 'STUTTERSHIT', 'ION10', 'RARBG', 'FaNGDiNG0', 'YIFY', 'FUM', 'ViSION', 'NhaNc3', - 'nSD', 'PRODJi', 'DNL', 'DeadFish', 'HDTime', 'mHD', 'TERMiNAL', - '[Oj]', 'QxR', 'ZmN', 'RDN', 'mSD', 'LOAD', 'BDP', 'SANTi', 'ZKBL', ['EVO', 'WEB-DL Only'] + 'aXXo', 'BRrip', 'CM8', 'CrEwSaDe', 'DNL', 'FaNGDiNG0', 'FRDS', 'HD2DVD', 'HDTime', 'iPlanet', + 'KiNGDOM', 'Leffe', 'mHD', 'mSD', 'nHD', 'nikt0', 'nSD', 'NhaNc3', 'PRODJi', 'RDN', 'SANTi', + 'STUTTERSHIT', 'TERMiNAL', 'ViSION', 'WAF', 'x0r', 'YIFY', ['EVO', 'WEB-DL Only'] ] pass - async def upload(self, meta): + # For loading + async def async_pickle_loads(self, data): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, pickle.loads, data) + + # For dumping + async def async_pickle_dumps(self, obj): + loop = asyncio.get_running_loop() + return await loop.run_in_executor(None, pickle.dumps, obj) + + async def upload(self, meta, disctype): common = COMMON(config=self.config) cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/MTV.pkl") + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename="BASE") + if not await aiofiles.os.path.exists(torrent_file_path): + torrent_filename = "BASE" + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent" + + loop = asyncio.get_running_loop() + torrent = await loop.run_in_executor(None, Torrent.read, torrent_file_path) + + if torrent.piece_size > 8388608: + tracker_config = self.config['TRACKERS'].get(self.tracker, {}) + if str(tracker_config.get('skip_if_rehash', 'false')).lower() == "false": + console.print("[red]Piece size is OVER 8M and does not work on MTV. Generating a new .torrent") + if meta.get('mkbrr', False): + tracker_url = config['TRACKERS']['MTV'].get('announce_url', "https://fake.tracker").strip() + + # Create the torrent with the tracker URL + torrent_create = f"[{self.tracker}]" + create_torrent(meta, meta['path'], torrent_create, tracker_url=tracker_url) + torrent_filename = "[MTV]" + + await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename=torrent_filename) + else: + meta['max_piece_size'] = '8' + if meta['is_disc']: + include = [] + exclude = [] + else: + include = ["*.mkv", "*.mp4", "*.ts"] + exclude = ["*.*", "*sample.mkv", "!sample*.*"] + + new_torrent = CustomTorrent( + meta=meta, + path=Path(meta['path']), + trackers=["https://fake.tracker"], + source="Audionut", + private=True, + exclude_globs=exclude, # Ensure this is always a list + include_globs=include, # Ensure this is always a list + creation_date=datetime.now(), + comment="Created by Upload Assistant", + created_by="Upload Assistant" + ) + + new_torrent.piece_size = 8 * 1024 * 1024 + new_torrent.validate_piece_size() + new_torrent.generate(callback=torf_cb, interval=5) + new_torrent.write(torrent_file_path, overwrite=True) + + torrent_filename = "[MTV]" + await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename=torrent_filename) - torrent_filename = "BASE" - if not Torrent.read(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent").piece_size <= 8388608: - console.print("[red]Piece size is OVER 8M and does not work on MTV. Generating a new .torrent") - from src.prep import Prep - prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=self.config) - prep.create_torrent(meta, Path(meta['path']), "MTV", piece_size_max=8) - torrent_filename = "MTV" - # Hash to f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent" - await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename=torrent_filename) - - # getting category HD Episode, HD Movies, SD Season, HD Season, SD Episode, SD Movies + else: + console.print("[red]Piece size is OVER 8M and skip_if_rehash enabled. Skipping upload.") + return + + approved_image_hosts = ['ptpimg', 'imgbox', 'imgbb'] + url_host_mapping = { + "ibb.co": "imgbb", + "ptpimg.me": "ptpimg", + "imgbox.com": "imgbox", + } + + await check_hosts(meta, self.tracker, url_host_mapping=url_host_mapping, img_host_index=1, approved_image_hosts=approved_image_hosts) cat_id = await self.get_cat_id(meta) - # res 480 720 1080 1440 2160 4k 6k Other resolution_id = await self.get_res_id(meta['resolution']) - # getting source HDTV SDTV TV Rip DVD DVD Rip VHS BluRay BDRip WebDL WebRip Mixed Unknown source_id = await self.get_source_id(meta) - # get Origin Internal Scene P2P User Mixed Other. P2P will be selected if not scene origin_id = await self.get_origin_id(meta) - # getting tags des_tags = await self.get_tags(meta) - # check for approved imghosts - approved_imghosts = ['ptpimg', 'imgbox', 'empornium', 'ibb'] - if not all(any(x in image['raw_url'] for x in approved_imghosts) for image in meta['image_list']): - console.print("[red]Unsupported image host detected, please use one of the approved imagehosts") - return - # getting description await self.edit_desc(meta) - # getting groups des so things like imdb link, tmdb link etc.. group_desc = await self.edit_group_desc(meta) - #poster is optional so no longer getting it as its a pain with having to use supported image provider - # poster = await self.get_poster(meta) - - #edit name to match MTV standards mtv_name = await self.edit_name(meta) - # anon - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): anon = 0 else: anon = 1 - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() + desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + async with aiofiles.open(desc_path, 'r', encoding='utf-8') as f: + desc = await f.read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') as f: - tfile = f.read() - f.close() + async with aiofiles.open(torrent_file_path, 'rb') as f: + tfile = await f.read() - ## todo need to check the torrent and make sure its not more than 8MB - - # need to pass the name of the file along with the torrent files = { - 'file_input': (f"{meta['name']}.torrent", tfile) + 'file_input': (f"[{self.tracker}].torrent", tfile) } data = { - # 'image': poster, 'image': '', 'title': mtv_name, 'category': cat_id, @@ -116,81 +164,112 @@ async def upload(self, meta): 'submit': 'true', } - # cookie = {'sid': self.config['TRACKERS'][self.tracker].get('sid'), 'cid': self.config['TRACKERS'][self.tracker].get('cid')} - - param = { - } - - if meta['imdb_id'] not in ("0", "", None): - param['imdbID'] = "tt" + meta['imdb_id'] - if meta['tmdb'] != 0: - param['tmdbID'] = meta['tmdb'] - if meta['tvdb_id'] != 0: - param['thetvdbID'] = meta['tvdb_id'] - if meta['tvmaze_id'] != 0: - param['tvmazeID'] = meta['tvmaze_id'] - # if meta['mal_id'] != 0: - # param['malid'] = meta['mal_id'] + if not meta['debug']: + try: + async with aiofiles.open(cookiefile, 'rb') as cf: + cookie_data = await cf.read() + cookies = await self.async_pickle_loads(cookie_data) + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } - if meta['debug'] == False: - with requests.Session() as session: - with open(cookiefile, 'rb') as cf: - session.cookies.update(pickle.load(cf)) - response = session.post(url=self.upload_url, data=data, files=files) - try: - if "torrents.php" in response.url: - console.print(response.url) - else: - if "authkey.php" in response.url: - console.print(f"[red]No DL link in response, So unable to download torrent but It may have uploaded, go check") - print(response.content) - console.print(f"[red]Got response code = {response.status_code}") - print(data) + async with httpx.AsyncClient( + cookies=cookies, + timeout=10.0, + follow_redirects=True, + headers=headers + ) as client: + + response = await client.post(url=self.upload_url, data=data, files=files) + + try: + if "torrents.php" in str(response.url): + meta['tracker_status'][self.tracker]['status_message'] = response.url + await common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.config['TRACKERS'][self.tracker].get('announce_url'), str(response.url)) + elif 'https://www.morethantv.me/upload.php' in str(response.url): + meta['tracker_status'][self.tracker]['status_message'] = "data error - Still on upload page - upload may have failed" + if "error" in response.text.lower() or "failed" in response.text.lower(): + meta['tracker_status'][self.tracker]['status_message'] = "data error - Upload failed - check form data" + elif str(response.url) == "https://www.morethantv.me/" or str(response.url) == "https://www.morethantv.me/index.php": + if "Project Luminance" in response.text: + meta['tracker_status'][self.tracker]['status_message'] = "data error - Not logged in - session may have expired" + if "'GroupID' cannot be null" in response.text: + meta['tracker_status'][self.tracker]['status_message'] = "data error - You are hitting this site bug: https://www.morethantv.me/forum/thread/3338?" + elif "Integrity constraint violation" in response.text: + meta['tracker_status'][self.tracker]['status_message'] = "data error - Proper site bug" else: - console.print(f"[red]Upload Failed, Doesnt look like you are logged in") - print(response.content) - print(data) - except: - console.print(f"[red]It may have uploaded, go check") - console.print(data) - print(traceback.print_exc()) + if "authkey.php" in str(response.url): + meta['tracker_status'][self.tracker]['status_message'] = "data error - No DL link in response, It may have uploaded, check manually." + else: + console.print(f"response URL: {response.url}") + console.print(f"response status: {response.status_code}") + except Exception: + meta['tracker_status'][self.tracker]['status_message'] = "data error -It may have uploaded, check manually." + print(traceback.print_exc()) + except (httpx.RequestError, Exception) as e: + meta['tracker_status'][self.tracker]['status_message'] = f"data error: {e}" + return else: - console.print(f"[cyan]Request Data:") + console.print("[cyan]MTV Request Data:") console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." return - async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as desc: - # adding bd_dump to description if it exits and adding empty string to mediainfo - if meta['bdinfo'] != None: + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8') as f: + base = await f.read() + + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf-8') as desc: + if meta['bdinfo'] is not None: mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') as f: + bd_dump = await f.read() else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt", 'r', encoding='utf-8').read()[:-65].strip() + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') as f: + mi_dump = (await f.read()).strip() bd_dump = None + if bd_dump: - desc.write("[mediainfo]" + bd_dump + "[/mediainfo]\n\n") + await desc.write("[mediainfo]" + bd_dump + "[/mediainfo]\n\n") elif mi_dump: - desc.write("[mediainfo]" + mi_dump + "[/mediainfo]\n\n") - images = meta['image_list'] + await desc.write("[mediainfo]" + mi_dump + "[/mediainfo]\n\n") + + if ( + meta.get('is_disc') == "DVD" and + isinstance(meta.get('discs'), list) and + len(meta['discs']) > 0 and + 'vob_mi' in meta['discs'][0] + ): + await desc.write("[mediainfo]" + meta['discs'][0]['vob_mi'] + "[/mediainfo]\n\n") + try: + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + console.print("[green]Adding tonemapped header to description") + tonemapped_header = self.config['DEFAULT'].get('tonemapped_header') + await desc.write(tonemapped_header) + await desc.write("\n\n") + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + if f'{self.tracker}_images_key' in meta: + images = meta[f'{self.tracker}_images_key'] + else: + images = meta['image_list'] if len(images) > 0: - desc.write(f"[spoiler=Screenshots]") - for each in range(len(images)): - raw_url = images[each]['raw_url'] - img_url = images[each]['img_url'] - desc.write(f"[url={raw_url}][img=250]{img_url}[/img][/url]") - desc.write(f"[/spoiler]") - desc.write(f"\n\n{base}") - desc.close() + for image in images: + raw_url = image['raw_url'] + img_url = image['img_url'] + await desc.write(f"[url={raw_url}][img=250]{img_url}[/img][/url]") + + base = re.sub(r'\[/?quote\]', '', base, flags=re.IGNORECASE).strip() + if base != "": + await desc.write(f"\n\n[spoiler=Notes]{base}[/spoiler]") + return async def edit_group_desc(self, meta): description = "" - if meta['imdb_id'] not in ("0", "", None): - description += f"https://www.imdb.com/title/tt{meta['imdb_id']}" + if meta['imdb_id'] != 0: + description += f"https://www.imdb.com/title/tt{meta['imdb']}" if meta['tmdb'] != 0: description += f"\nhttps://www.themoviedb.org/{str(meta['category'].lower())}/{str(meta['tmdb'])}" if meta['tvdb_id'] != 0: @@ -202,86 +281,77 @@ async def edit_group_desc(self, meta): return description - async def edit_name(self, meta): - mtv_name = meta['uuid'] - # Try to use original filename if possible - if meta['source'].lower().replace('-', '') in mtv_name.replace('-', '').lower(): - if not meta['isdir']: - mtv_name = os.path.splitext(mtv_name)[0] + KNOWN_EXTENSIONS = {".mkv", ".mp4", ".avi", ".ts"} + if meta['scene'] is True: + if meta.get('scene_name') != "": + mtv_name = meta.get('scene_name') + else: + mtv_name = meta['uuid'] + base, ext = os.path.splitext(mtv_name) + if ext.lower() in KNOWN_EXTENSIONS: + mtv_name = base else: mtv_name = meta['name'] - if meta.get('type') in ('WEBDL', 'WEBRIP', 'ENCODE') and "DD" in meta['audio']: - mtv_name = mtv_name.replace(meta['audio'], meta['audio'].replace(' ', '', 1)) - mtv_name = mtv_name.replace(meta.get('aka', ''), '') - if meta['category'] == "TV" and meta.get('tv_pack', 0) == 0 and meta.get('episode_title_storage', '').strip() != '' and meta['episode'].strip() != '': - mtv_name = mtv_name.replace(meta['episode'], f"{meta['episode']} {meta['episode_title_storage']}") + prefix_removed = False + replacement_prefix = "" + + # Check for Dual-Audio or Dubbed prefix + if "Dual-Audio " in mtv_name: + prefix_removed = True + prefix_index = mtv_name.find("Dual-Audio ") + replacement_prefix = "DUAL " + mtv_name = mtv_name[:prefix_index] + mtv_name[prefix_index + len("Dual-Audio "):] + elif "Dubbed " in mtv_name: + prefix_removed = True + prefix_index = mtv_name.find("Dubbed ") + replacement_prefix = "DUBBED " + mtv_name = mtv_name[:prefix_index] + mtv_name[prefix_index + len("Dubbed "):] + + audio_str = meta['audio'] + if prefix_removed: + audio_str = audio_str.replace("Dual-Audio ", "").replace("Dubbed ", "") + + if prefix_removed and prefix_index != -1: + mtv_name = f"{mtv_name[:prefix_index]}{replacement_prefix}{mtv_name[prefix_index:].lstrip()}" + + if meta.get('type') in ('WEBDL', 'WEBRIP', 'ENCODE') and "DD" in audio_str: + mtv_name = mtv_name.replace(audio_str, audio_str.replace(' ', '', 1)) if 'DD+' in meta.get('audio', '') and 'DDP' in meta['uuid']: mtv_name = mtv_name.replace('DD+', 'DDP') - mtv_name = mtv_name.replace('Dubbed', '').replace('Dual-Audio', 'DUAL') - # Add -NoGrp if missing tag - if meta['tag'] == "": - mtv_name = f"{mtv_name}-NoGrp" + + if meta['source'].lower().replace('-', '') in mtv_name.replace('-', '').lower(): + if not meta['isdir']: + # Check if there is a valid file extension, otherwise, skip the split + if '.' in mtv_name and mtv_name.split('.')[-1].isalpha() and len(mtv_name.split('.')[-1]) <= 4: + mtv_name = os.path.splitext(mtv_name)[0] + + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + mtv_name = re.sub(f"-{invalid_tag}", "", mtv_name, flags=re.IGNORECASE) + mtv_name = f"{mtv_name}-NoGRP" + mtv_name = ' '.join(mtv_name.split()) - mtv_name = re.sub("[^0-9a-zA-ZÀ-ÿ. &+'\-\[\]]+", "", mtv_name) + mtv_name = re.sub(r"[^0-9a-zA-ZÀ-ÿ. &+'\-\[\]]+", "", mtv_name) mtv_name = mtv_name.replace(' ', '.').replace('..', '.') return mtv_name - - - # Not needed as its optional - # async def get_poster(self, meta): - # if 'poster_image' in meta: - # return meta['poster_image'] - # else: - # if meta['poster'] is not None: - # poster = meta['poster'] - # else: - # if 'cover' in meta['imdb_info'] and meta['imdb_info']['cover'] is not None: - # poster = meta['imdb_info']['cover'] - # else: - # console.print(f'[red]No poster can be found for this EXITING!!') - # return - # with requests.get(url=poster, stream=True) as r: - # with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['clean_name']}-poster.jpg", - # 'wb') as f: - # shutil.copyfileobj(r.raw, f) - # - # url = "https://api.imgbb.com/1/upload" - # data = { - # 'key': self.config['DEFAULT']['imgbb_api'], - # 'image': base64.b64encode(open(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['clean_name']}-poster.jpg", "rb").read()).decode('utf8') - # } - # try: - # console.print("[yellow]uploading poster to imgbb") - # response = requests.post(url, data=data) - # response = response.json() - # if response.get('success') != True: - # console.print(response, 'red') - # img_url = response['data'].get('medium', response['data']['image'])['url'] - # th_url = response['data']['thumb']['url'] - # web_url = response['data']['url_viewer'] - # raw_url = response['data']['image']['url'] - # meta['poster_image'] = raw_url - # console.print(f'[green]{raw_url} ') - # except Exception: - # console.print("[yellow]imgbb failed to upload cover") - # - # return raw_url async def get_res_id(self, resolution): resolution_id = { - '8640p':'0', + '8640p': '0', '4320p': '4000', '2160p': '2160', - '1440p' : '1440', + '1440p': '1440', '1080p': '1080', - '1080i':'1080', + '1080i': '1080', '720p': '720', '576p': '0', '576i': '0', '480p': '480', '480i': '480' - }.get(resolution, '10') + }.get(resolution, '10') return resolution_id async def get_cat_id(self, meta): @@ -302,7 +372,6 @@ async def get_cat_id(self, meta): else: return 3 - async def get_source_id(self, meta): if meta['is_disc'] == 'DVD': return '1' @@ -323,10 +392,9 @@ async def get_source_id(self, meta): 'MIXED': '11', 'Unknown': '12', 'ENCODE': '7' - }.get(meta['type'], '0') + }.get(meta['type'], '0') return type_id - async def get_origin_id(self, meta): if meta['personalrelease']: return '4' @@ -336,11 +404,12 @@ async def get_origin_id(self, meta): else: return '3' - async def get_tags(self, meta): tags = [] # Genres - tags.extend([x.strip().lower() for x in meta['genres'].split()]) + # MTV takes issue with some of the pulled TMDB tags, and I'm not hand checking and attempting + # to regex however many tags need changing, so they're just geting skipped + # tags.extend([x.strip(', ').lower().replace(' ', '.') for x in meta['genres'].split(',')]) # Resolution tags.append(meta['resolution'].lower()) if meta['sd'] == 1: @@ -350,37 +419,37 @@ async def get_tags(self, meta): else: tags.append('hd') # Streaming Service + # disney+ should be disneyplus, assume every other service is same. + # If I'm wrong, then they can either allowing editing tags or service will just get skipped also if str(meta['service_longname']) != "": - tags.append(f"{meta['service_longname'].lower().replace(' ', '.')}.source") + service_name = meta['service_longname'].lower().replace(' ', '.') + service_name = service_name.replace('+', 'plus') # Replace '+' with 'plus' + tags.append(f"{service_name}.source") # Release Type/Source for each in ['remux', 'WEB.DL', 'WEBRip', 'HDTV', 'BluRay', 'DVD', 'HDDVD']: if (each.lower().replace('.', '') in meta['type'].lower()) or (each.lower().replace('-', '') in meta['source']): tags.append(each) - - # series tags if meta['category'] == "TV": if meta.get('tv_pack', 0) == 0: # Episodes if meta['sd'] == 1: - tags.extend(['episode.release', 'sd.episode']) + tags.extend(['sd.episode']) else: - tags.extend(['episode.release', 'hd.episode']) + tags.extend(['hd.episode']) else: # Seasons if meta['sd'] == 1: tags.append('sd.season') else: tags.append('hd.season') - + # movie tags if meta['category'] == 'MOVIE': if meta['sd'] == 1: tags.append('sd.movie') else: tags.append('hd.movie') - - # Audio tags audio_tag = "" @@ -417,149 +486,240 @@ async def get_tags(self, meta): tags = ' '.join(tags) return tags - - async def validate_credentials(self, meta): cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/MTV.pkl") - if not os.path.exists(cookiefile): + if not await aiofiles.os.path.exists(cookiefile): await self.login(cookiefile) vcookie = await self.validate_cookies(meta, cookiefile) - if vcookie != True: + if vcookie is not True: console.print('[red]Failed to validate cookies. Please confirm that the site is up and your username and password is valid.') - recreate = cli_ui.ask_yes_no("Log in again and create new session?") - if recreate == True: - if os.path.exists(cookiefile): - os.remove(cookiefile) + if 'mtv_timeout' in meta and meta['mtv_timeout']: + meta['skipping'] = "MTV" + return False + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + recreate = cli_ui.ask_yes_no("Log in again and create new session?") + else: + recreate = True + if recreate is True: + if await aiofiles.os.path.exists(cookiefile): + await aiofiles.os.remove(cookiefile) # Using async file removal await self.login(cookiefile) vcookie = await self.validate_cookies(meta, cookiefile) return vcookie else: return False vapi = await self.validate_api() - if vapi != True: + if vapi is not True: console.print('[red]Failed to validate API. Please confirm that the site is up and your API key is valid.') return True async def validate_api(self): url = self.search_url params = { - 'apikey' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), + 'apikey': self.config['TRACKERS'][self.tracker]['api_key'].strip(), } try: - r = requests.get(url, params=params) - if not r.ok: - if "unauthorized api key" in r.text.lower(): - console.print("[red]Invalid API Key") - return False - return True - except: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.get(url=url, params=params) + if not response.is_success: + if "unauthorized api key" in response.text.lower(): + console.print("[red]Invalid API Key") + return False + return True + except Exception: return False async def validate_cookies(self, meta, cookiefile): url = "https://www.morethantv.me/index.php" - if os.path.exists(cookiefile): - with requests.Session() as session: - with open(cookiefile, 'rb') as cf: - session.cookies.update(pickle.load(cf)) - resp = session.get(url=url) - if meta['debug']: - console.log('[cyan]Validate Cookies:') - console.log(session.cookies.get_dict()) - console.log(resp.url) - if resp.text.find("Logout") != -1: - return True - else: - return False + if await aiofiles.os.path.exists(cookiefile): + try: + + async with aiofiles.open(cookiefile, 'rb') as cf: + data = await cf.read() + cookies_dict = await self.async_pickle_loads(data) + + async with httpx.AsyncClient(cookies=cookies_dict, timeout=10) as client: + try: + resp = await client.get(url=url) + if meta['debug']: + console.print('[cyan]Validating Cookies:') + + if "Logout" in resp.text: + return True + else: + console.print("[yellow]Valid session not found in cookies") + return False + + except httpx.TimeoutException: + console.print(f"[red]Connection to {url} timed out. The site may be down or unreachable.") + meta['mtv_timeout'] = True + return False + except httpx.ConnectError: + console.print(f"[red]Failed to connect to {url}. The site may be down or your connection is blocked.") + meta['mtv_timeout'] = True + return False + except Exception as e: + console.print(f"[red]Error connecting to MTV: {str(e)}") + return False + except Exception as e: + console.print(f"[red]Error loading cookies: {str(e)}") + return False else: + console.print("[yellow]Cookie file not found") return False async def get_auth(self, cookiefile): url = "https://www.morethantv.me/index.php" - if os.path.exists(cookiefile): - with requests.Session() as session: - with open(cookiefile, 'rb') as cf: - session.cookies.update(pickle.load(cf)) - resp = session.get(url=url) - auth = resp.text.rsplit('authkey=', 1)[1][:32] - return auth + try: + if await aiofiles.os.path.exists(cookiefile): + async with aiofiles.open(cookiefile, 'rb') as cf: + data = await cf.read() + cookies = await self.async_pickle_loads(data) + + async with httpx.AsyncClient(cookies=cookies, timeout=10) as client: + try: + resp = await client.get(url=url) + if "authkey=" in resp.text: + auth = resp.text.rsplit('authkey=', 1)[1][:32] + return auth + else: + console.print("[yellow]Auth key not found in response") + return "" + except httpx.RequestError as e: + console.print(f"[red]Error getting auth key: {str(e)}") + return "" + else: + console.print("[yellow]Cookie file not found for auth key retrieval") + return "" + except Exception as e: + console.print(f"[red]Unexpected error retrieving auth key: {str(e)}") + return "" async def login(self, cookiefile): - with requests.Session() as session: - url = 'https://www.morethantv.me/login' - payload = { - 'username' : self.config['TRACKERS'][self.tracker].get('username'), - 'password' : self.config['TRACKERS'][self.tracker].get('password'), - 'keeploggedin' : 1, - 'cinfo' : '1920|1080|24|0', - 'submit' : 'login', - 'iplocked' : 1, - # 'ssl' : 'yes' - } - res = session.get(url="https://www.morethantv.me/login") - token = res.text.rsplit('name="token" value="', 1)[1][:48] - # token and CID from cookie needed for post to login - payload["token"] = token - resp = session.post(url=url, data=payload) - - # handle 2fa - if resp.url.endswith('twofactor/login'): - otp_uri = self.config['TRACKERS'][self.tracker].get('otp_uri') - if otp_uri: - import pyotp - mfa_code = pyotp.parse_uri(otp_uri).now() - else: - mfa_code = console.input('[yellow]MTV 2FA Code: ') - - two_factor_payload = { - 'token' : resp.text.rsplit('name="token" value="', 1)[1][:48], - 'code' : mfa_code, - 'submit' : 'login' + try: + async with httpx.AsyncClient(timeout=25, follow_redirects=True) as client: + url = 'https://www.morethantv.me/login' + payload = { + 'username': self.config['TRACKERS'][self.tracker].get('username'), + 'password': self.config['TRACKERS'][self.tracker].get('password'), + 'keeploggedin': 1, + 'cinfo': '1920|1080|24|0', + 'submit': 'login', + 'iplocked': 1, } - resp = session.post(url="https://www.morethantv.me/twofactor/login", data=two_factor_payload) - # checking if logged in - if 'authkey=' in resp.text: - console.print('[green]Successfully logged in to MTV') - with open(cookiefile, 'wb') as cf: - pickle.dump(session.cookies, cf) - else: - console.print('[bold red]Something went wrong while trying to log into MTV') - await asyncio.sleep(1) - console.print(resp.url) - return - async def search_existing(self, meta): + try: + res = await client.get(url="https://www.morethantv.me/login") + + if 'name="token" value="' not in res.text: + console.print("[red]Unable to find token in login page") + return False + + token = res.text.rsplit('name="token" value="', 1)[1][:48] + + payload["token"] = token + resp = await client.post(url=url, data=payload) + + if str(resp.url).endswith('twofactor/login'): + + otp_uri = self.config['TRACKERS'][self.tracker].get('otp_uri') + if otp_uri: + mfa_code = pyotp.parse_uri(otp_uri).now() + else: + mfa_code = console.input('[yellow]MTV 2FA Code: ') + + two_factor_token = resp.text.rsplit('name="token" value="', 1)[1][:48] + two_factor_payload = { + 'token': two_factor_token, + 'code': mfa_code, + 'submit': 'login' + } + resp = await client.post(url="https://www.morethantv.me/twofactor/login", data=two_factor_payload) + + await asyncio.sleep(1) + if 'authkey=' in resp.text: + console.print('[green]Successfully logged in to MTV') + cookies_dict = dict(client.cookies) + cookies_data = await self.async_pickle_dumps(cookies_dict) + async with aiofiles.open(cookiefile, 'wb') as cf: + await cf.write(cookies_data) + console.print(f"[green]Cookies saved to {cookiefile}") + return True + else: + console.print('[bold red]Something went wrong while trying to log into MTV') + console.print(f"[red]Final URL: {resp.url}") + return False + + except httpx.TimeoutException: + console.print("[red]Connection to MTV timed out. The site may be down or unreachable.") + return False + except httpx.ConnectError: + console.print("[red]Failed to connect to MTV. The site may be down or your connection is blocked.") + return False + except Exception as e: + console.print(f"[red]Error during MTV login: {str(e)}") + console.print(f"[dim red]{traceback.format_exc()}[/dim red]") + return False + except Exception as e: + console.print(f"[red]Unexpected error during login: {str(e)}") + console.print(f"[dim red]{traceback.format_exc()}[/dim red]") + return False + + async def search_existing(self, meta, disctype): dupes = [] - console.print("[yellow]Searching for existing torrents on site...") + + # Build request parameters params = { - 't' : 'search', - 'apikey' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'q' : "" + 't': 'search', + 'apikey': self.config['TRACKERS'][self.tracker]['api_key'].strip(), + 'q': "", + 'limit': "100" } - if meta['imdb_id'] not in ("0", "", None): - params['imdbid'] = "tt" + meta['imdb_id'] - elif meta['tmdb'] != "0": - params['tmdbid'] = meta['tmdb'] + + if meta['imdb_id'] != 0: + params['imdbid'] = "tt" + str(meta['imdb']) + elif meta['tmdb'] != 0: + params['tmdbid'] = str(meta['tmdb']) elif meta['tvdb_id'] != 0: - params['tvdbid'] = meta['tvdb_id'] + params['tvdbid'] = str(meta['tvdb_id']) else: params['q'] = meta['title'].replace(': ', ' ').replace('’', '').replace("'", '') try: - rr = requests.get(url=self.search_url, params=params) - if rr is not None: - # process search results - response_xml = xml.etree.ElementTree.fromstring(rr.text) - for each in response_xml.find('channel').findall('item'): - result = each.find('title').text - dupes.append(result) - else: - if 'status_message' in rr: - console.print(f"[yellow]{rr.get('status_message')}") - await asyncio.sleep(5) + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url=self.search_url, params=params) + + if response.status_code == 200 and response.text: + # Parse XML response + try: + loop = asyncio.get_running_loop() + response_xml = await loop.run_in_executor(None, ET.fromstring, response.text) + for each in response_xml.find('channel').findall('item'): + result = { + 'name': each.find('title').text, + 'files': each.find('title').text, + 'file_count': each.find('files').text, + 'size': each.find('size').text, + 'link': each.find('guid').text + } + dupes.append(result) + except ET.ParseError: + console.print("[red]Failed to parse XML response from MTV API") else: - console.print(f"[red]Site Seems to be down or not responding to API") - except: - console.print(f"[red]Unable to search for existing torrents on site. Most likely the site is down.") + # Handle potential error messages + if response.status_code != 200: + console.print(f"[red]HTTP request failed. Status: {response.status_code}") + elif 'status_message' in response.json(): + console.print(f"[yellow]{response.json().get('status_message')}") + await asyncio.sleep(5) + else: + console.print("[red]Site Seems to be down or not responding to API") + except httpx.TimeoutException: + console.print("[red]Request timed out after 5 seconds") + except httpx.RequestError as e: + console.print(f"[red]Unable to search for existing torrents: {e}") + except Exception: + console.print("[red]Unable to search for existing torrents on site. Most likely the site is down.") dupes.append("FAILED SEARCH") print(traceback.print_exc()) await asyncio.sleep(5) diff --git a/src/trackers/NBL.py b/src/trackers/NBL.py index 489c21902..266746f0f 100644 --- a/src/trackers/NBL.py +++ b/src/trackers/NBL.py @@ -1,10 +1,7 @@ # -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os -from guessit import guessit +import json +import aiofiles +import httpx from src.trackers.COMMON import COMMON from src.console import console @@ -19,12 +16,6 @@ class NBL(): Upload """ - ############################################################### - ######## EDIT ME ######## - ############################################################### - - # ALSO EDIT CLASS NAME ABOVE - def __init__(self, config): self.config = config self.tracker = 'NBL' @@ -32,9 +23,14 @@ def __init__(self, config): self.upload_url = 'https://nebulance.io/upload.php' self.search_url = 'https://nebulance.io/api.php' self.api_key = self.config['TRACKERS'][self.tracker]['api_key'].strip() - self.banned_groups = ['0neshot', '3LTON', '4yEo', '[Oj]', 'AFG', 'AkihitoSubs', 'AniHLS', 'Anime', 'Time', 'AnimeRG', 'AniURL', 'ASW', 'BakedFish', 'bonkai77', 'Cleo', 'DeadFish', 'DeeJayAhmed', 'ELiTE', 'EMBER', 'eSc', 'FGT', 'FUM', 'GERMini', 'HAiKU', 'Hi10', 'ION10', 'JacobSwaggedUp', 'JIVE', 'Judas', 'LOAD', 'MeGusta', 'Mr.Deadpool', 'mSD', 'NemDiggers', 'neoHEVC', 'NhaNc3', 'NOIVTC', 'PlaySD', 'playXD', 'project-gxs', 'PSA', 'QaS', 'Ranger', 'RAPiDCOWS', 'Raze', 'Reaktor', 'REsuRRecTioN', 'RMTeam', 'SpaceFish', 'SPASM', 'SSA', 'Telly', 'Tenrai-Sensei', 'TM', 'Trix', 'URANiME', 'VipapkStudios', 'ViSiON', 'Wardevil', 'xRed', 'XS', 'YakuboEncodes', 'YuiSubs', 'ZKBL', 'ZmN', 'ZMNT'] + self.banned_groups = ['0neshot', '3LTON', '4yEo', '[Oj]', 'AFG', 'AkihitoSubs', 'AniHLS', 'Anime Time', 'AnimeRG', 'AniURL', 'ASW', 'BakedFish', + 'bonkai77', 'Cleo', 'DeadFish', 'DeeJayAhmed', 'ELiTE', 'EMBER', 'eSc', 'EVO', 'FGT', 'FUM', 'GERMini', 'HAiKU', 'Hi10', 'ION10', + 'JacobSwaggedUp', 'JIVE', 'Judas', 'LOAD', 'MeGusta', 'Mr.Deadpool', 'mSD', 'NemDiggers', 'neoHEVC', 'NhaNc3', 'NOIVTC', + 'PlaySD', 'playXD', 'project-gxs', 'PSA', 'QaS', 'Ranger', 'RAPiDCOWS', 'Raze', 'Reaktor', 'REsuRRecTioN', 'RMTeam', 'ROBOTS', + 'SpaceFish', 'SPASM', 'SSA', 'Telly', 'Tenrai-Sensei', 'TM', 'Trix', 'URANiME', 'VipapkStudios', 'ViSiON', 'Wardevil', 'xRed', + 'XS', 'YakuboEncodes', 'YuiSubs', 'ZKBL', 'ZmN', 'ZMNT'] + pass - async def get_cat_id(self, meta): if meta.get('tv_pack', 0) == 1: @@ -43,98 +39,124 @@ async def get_cat_id(self, meta): cat_id = 1 return cat_id - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### async def edit_desc(self, meta): # Leave this in so manual works return - async def upload(self, meta): - if meta['category'] != 'TV': - console.print("[red]Only TV Is allowed at NBL") - return + async def upload(self, meta, disctype): common = COMMON(config=self.config) await common.edit_torrent(meta, self.tracker, self.source_flag) - if meta['bdinfo'] != None: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() + if meta['bdinfo'] is not None: + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') as f: + mi_dump = await f.read() else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read()[:-65].strip() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'file_input': open_torrent} + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') as f: + mi_dump = await f.read() + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + async with aiofiles.open(torrent_file_path, 'rb') as f: + torrent_bytes = await f.read() + files = {'file_input': ('torrent.torrent', torrent_bytes, 'application/x-bittorrent')} data = { - 'api_key' : self.api_key, - 'tvmazeid' : int(meta.get('tvmaze_id', 0)), - 'mediainfo' : mi_dump, - 'category' : await self.get_cat_id(meta), - 'ignoredupes' : 'on' + 'api_key': self.api_key, + 'tvmazeid': int(meta.get('tvmaze_id', 0)), + 'mediainfo': mi_dump, + 'category': await self.get_cat_id(meta), + 'ignoredupes': 'on' } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data) - try: - if response.ok: - response = response.json() - console.print(response.get('message', response)) - else: - console.print(response) - console.print(response.text) - except: - console.print_exception() - console.print("[bold yellow]It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() + try: + if not meta['debug']: + async with httpx.AsyncClient(timeout=10) as client: + response = await client.post(url=self.upload_url, files=files, data=data) + if response.status_code in [200, 201]: + try: + response_data = response.json() + except json.JSONDecodeError: + meta['tracker_status'][self.tracker]['status_message'] = "data error: NBL json decode error, the API is probably down" + return + else: + response_data = { + "error": f"Unexpected status code: {response.status_code}", + "response_content": response.text + } + meta['tracker_status'][self.tracker]['status_message'] = response_data + else: + console.print("[cyan]NBL Request Data:") + console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." + except Exception as e: + meta['tracker_status'][self.tracker]['status_message'] = f"data error: Upload failed: {e}" - + async def search_existing(self, meta, disctype): + if meta['category'] != 'TV': + if not meta['unattended']: + console.print("[red]Only TV Is allowed at NBL") + meta['skipping'] = "NBL" + return [] + if meta.get('is_disc') is not None: + if not meta['unattended']: + console.print('[bold red]NBL does not allow raw discs') + meta['skipping'] = "NBL" + return [] - async def search_existing(self, meta): dupes = [] - console.print("[yellow]Searching for existing torrents on site...") + if int(meta.get('tvmaze_id', 0)) != 0: - search_term = {'tvmaze' : int(meta['tvmaze_id'])} - elif int(meta.get('imdb_id', '0').replace('tt', '')) == 0: - search_term = {'imdb' : meta.get('imdb_id', '0').replace('tt', '')} + search_term = {'tvmaze': int(meta['tvmaze_id'])} + elif int(meta.get('imdb_id')) != 0: + search_term = {'imdb': meta.get('imdb')} else: - search_term = {'series' : meta['title']} - json = { - 'jsonrpc' : '2.0', - 'id' : 1, - 'method' : 'getTorrents', - 'params' : [ - self.api_key, + search_term = {'series': meta['title']} + payload = { + 'jsonrpc': '2.0', + 'id': 1, + 'method': 'getTorrents', + 'params': [ + self.api_key, search_term ] } + try: - response = requests.get(url=self.search_url, json=json) - response = response.json() - for each in response['result']['items']: - if meta['resolution'] in each['tags']: - if meta.get('tv_pack', 0) == 1: - if each['cat'] == "Season" and int(guessit(each['rls_name']).get('season', '1')) == int(meta.get('season_int')): - dupes.append(each['rls_name']) - elif int(guessit(each['rls_name']).get('episode', '0')) == int(meta.get('episode_int')): - dupes.append(each['rls_name']) - except requests.exceptions.JSONDecodeError: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.post(self.search_url, json=payload) + if response.status_code == 200: + try: + data = response.json() + for each in data.get('result', {}).get('items', []): + if meta['resolution'] in each.get('tags', []): + file_list = each.get('file_list', []) + result = { + 'name': each.get('rls_name', ''), + 'files': ', '.join(file_list) if isinstance(file_list, list) else str(file_list), + 'size': int(each.get('size', 0)), + 'link': f'https://nebulance.io/torrents.php?id={each.get("group_id", "")}', + 'file_count': len(file_list) if isinstance(file_list, list) else 1, + } + dupes.append(result) + except json.JSONDecodeError: + console.print("[bold yellow]NBL response content is not valid JSON. Skipping this API call.") + meta['skipping'] = "NBL" + else: + console.print(f"[bold red]NBL HTTP request failed. Status: {response.status_code}") + meta['skipping'] = "NBL" + + except httpx.TimeoutException: + console.print("[bold red]NBL request timed out after 5 seconds") + meta['skipping'] = "NBL" + except httpx.RequestError as e: + console.print(f"[bold red]NBL an error occurred while making the request: {e}") + meta['skipping'] = "NBL" except KeyError as e: - console.print(response) - console.print("\n\n\n") - if e.args[0] == 'result': - console.print(f"Search Term: {search_term}") - console.print('[red]NBL API Returned an unexpected response, please manually check for dupes') + console.print(f"[bold red]Unexpected KeyError: {e}") + if 'result' not in response.json(): + console.print("[red]NBL API returned an unexpected response. Please manually check for dupes.") dupes.append("ERROR: PLEASE CHECK FOR EXISTING RELEASES MANUALLY") - await asyncio.sleep(5) - else: - console.print_exception() - except Exception: + except Exception as e: + meta['skipping'] = "NBL" + console.print(f"[bold red]NBL unexpected error: {e}") console.print_exception() - return dupes \ No newline at end of file + return dupes diff --git a/src/trackers/OE.py b/src/trackers/OE.py index bb69a3e02..6e52d3fef 100644 --- a/src/trackers/OE.py +++ b/src/trackers/OE.py @@ -1,128 +1,186 @@ # -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -from difflib import SequenceMatcher -import distutils.util -import json +import aiofiles import os -import platform - -from src.trackers.COMMON import COMMON +import re +from src.bbcode import BBCODE from src.console import console +from src.languages import process_desc_language, has_english_language +from src.rehostimages import check_hosts +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D -class OE(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ + +class OE(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='OE') self.config = config + self.common = COMMON(config) self.tracker = 'OE' self.source_flag = 'OE' - self.search_url = 'https://onlyencodes.cc/api/torrents/filter' - self.upload_url = 'https://onlyencodes.cc/api/torrents/upload' - self.signature = f"\n[center][url=https://onlyencodes.cc/pages/1]OnlyEncodes Uploader - Powered by L4G's Upload Assistant[/url][/center]" - self.banned_groups = ['0neshot', '3LT0N', '4K4U', '4yEo', '$andra', '[Oj]', 'AFG', 'AkihitoSubs', 'AniHLS', 'Anime Time', 'AnimeRG', 'AniURL', 'AR', 'AROMA', 'ASW', 'aXXo', 'BakedFish', 'BiTOR', 'BHDStudio', 'BRrip', 'bonkai', 'Cleo', 'CM8', 'C4K', 'CrEwSaDe', 'core', 'd3g', 'DDR', 'DeadFish', 'DeeJayAhmed', 'DNL', 'ELiTE', 'EMBER', 'eSc', 'EVO', 'EZTV', 'FaNGDiNG0', 'FGT', 'fenix', 'FUM', 'FRDS', 'FROZEN', 'GalaxyTV', 'GalaxyRG', 'GERMini', 'Grym', 'GrymLegacy', 'HAiKU', 'HD2DVD', 'HDTime', 'Hi10', 'ION10', 'iPlanet', 'JacobSwaggedUp', 'JIVE', 'Judas', 'KiNGDOM', 'LAMA', 'Leffe', 'LiGaS', 'LOAD', 'LycanHD', 'MeGusta,' 'MezRips,' 'mHD,' 'Mr.Deadpool', 'mSD', 'NemDiggers', 'neoHEVC', 'NeXus', 'NhaNc3', 'nHD', 'nikt0', 'nSD', 'NhaNc3', 'NOIVTC', 'pahe.in', 'PlaySD', 'playXD', 'PRODJi', 'ProRes', 'project-gxs', 'PSA', 'QaS', 'Ranger', 'RAPiDCOWS', 'RARBG', 'Raze', 'RCDiVX', 'RDN', 'Reaktor', 'REsuRRecTioN', 'RMTeam', 'ROBOTS', 'rubix', 'SANTi', 'SHUTTERSHIT', 'SpaceFish', 'SPASM', 'SSA', 'TBS', 'Telly,' 'Tenrai-Sensei,' 'TERMiNAL,' 'TM', 'topaz', 'TSP', 'TSPxL', 'Trix', 'URANiME', 'UTR', 'VipapkSudios', 'ViSION', 'WAF', 'Wardevil', 'x0r', 'xRed', 'XS', 'YakuboEncodes', 'YIFY', 'YTS', 'YuiSubs', 'ZKBL', 'ZmN', 'ZMNT'] + self.base_url = 'https://onlyencodes.cc' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + '0neshot', '3LT0N', '4K4U', '4yEo', '$andra', '[Oj]', 'AFG', 'AkihitoSubs', 'AniHLS', 'Anime Time', + 'AnimeRG', 'AniURL', 'AOC', 'AR', 'AROMA', 'ASW', 'aXXo', 'BakedFish', 'BiTOR', 'BRrip', 'bonkai', + 'Cleo', 'CM8', 'C4K', 'CrEwSaDe', 'core', 'd3g', 'DDR', 'DE3PM', 'DeadFish', 'DeeJayAhmed', 'DNL', 'ELiTE', + 'EMBER', 'eSc', 'EVO', 'EZTV', 'FaNGDiNG0', 'FGT', 'fenix', 'FUM', 'FRDS', 'FROZEN', 'GalaxyTV', + 'GalaxyRG', 'GalaxyRG265', 'GERMini', 'Grym', 'GrymLegacy', 'HAiKU', 'HD2DVD', 'HDTime', 'Hi10', + 'HiQVE', 'ION10', 'iPlanet', 'JacobSwaggedUp', 'JIVE', 'Judas', 'KiNGDOM', 'LAMA', 'Leffe', 'LiGaS', + 'LOAD', 'LycanHD', 'MeGusta', 'MezRips', 'mHD', 'Mr.Deadpool', 'mSD', 'NemDiggers', 'neoHEVC', 'NeXus', + 'nHD', 'nikt0', 'nSD', 'NhaNc3', 'NOIVTC', 'pahe.in', 'PlaySD', 'playXD', 'PRODJi', 'ProRes', + 'project-gxs', 'PSA', 'QaS', 'Ranger', 'RAPiDCOWS', 'RARBG', 'Raze', 'RCDiVX', 'RDN', 'Reaktor', + 'REsuRRecTioN', 'RMTeam', 'ROBOTS', 'rubix', 'SANTi', 'SHUTTERSHIT', 'SpaceFish', 'SPASM', 'SSA', + 'TBS', 'Telly', 'Tenrai-Sensei', 'TERMiNAL', 'TGx', 'TM', 'topaz', 'TSP', 'TSPxL', 'URANiME', 'UTR', + 'VipapkSudios', 'ViSION', 'WAF', 'Wardevil', 'x0r', 'xRed', 'XS', 'YakuboEncodes', 'YIFY', 'YTS', + 'YuiSubs', 'ZKBL', 'ZmN', 'ZMNT' + ] pass - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type'], meta.get('tv_pack', 0), meta.get('video_codec'), meta.get('category', "")) - resolution_id = await self.get_res_id(meta['resolution']) - oe_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : oe_name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() + + async def get_additional_checks(self, meta): + should_continue = True + + disallowed_keywords = {'XXX', 'softcore', 'concert'} + if any(keyword.lower() in disallowed_keywords for keyword in map(str.lower, meta['keywords'])): + if not meta['unattended']: + console.print('[bold red]Erotic not allowed at OE.') + should_continue = False + + if not meta['is_disc'] == "BDMV": + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + if not await has_english_language(meta.get('audio_languages')) and not await has_english_language(meta.get('subtitle_languages')): + if not meta['unattended']: + console.print('[bold red]OE requires at least one English audio or subtitle track.') + should_continue = False + + return should_continue + + async def get_description(self, meta): + approved_image_hosts = ['ptpimg', 'imgbox', 'imgbb', 'onlyimage', 'ptscreens', "passtheimage"] + url_host_mapping = { + "ibb.co": "imgbb", + "ptpimg.me": "ptpimg", + "imgbox.com": "imgbox", + "onlyimage.org": "onlyimage", + "imagebam.com": "bam", + "ptscreens.com": "ptscreens", + "img.passtheima.ge": "passtheimage", } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) + + await check_hosts(meta, self.tracker, url_host_mapping=url_host_mapping, img_host_index=1, approved_image_hosts=approved_image_hosts) + + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf8') as f: + base = await f.read() + + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf8') as descfile: + await process_desc_language(meta, descfile, tracker=self.tracker) + + bbcode = BBCODE() + if meta.get('discs', []) != []: + discs = meta['discs'] + if discs[0]['type'] == "DVD": + await descfile.write(f"[spoiler=VOB MediaInfo][code]{discs[0]['vob_mi']}[/code][/spoiler]\n\n") + if len(discs) >= 2: + for each in discs[1:]: + if each['type'] == "BDMV": + await descfile.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]\n\n") + elif each['type'] == "DVD": + await descfile.write(f"{each['name']}:\n") + await descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code][{each['vob_mi']}[/code][/spoiler] [spoiler={os.path.basename(each['ifo'])}][code][{each['ifo_mi']}[/code][/spoiler]\n\n") + elif each['type'] == "HDDVD": + await descfile.write(f"{each['name']}:\n") + await descfile.write(f"[spoiler={os.path.basename(each['largest_evo'])}][code][{each['evo_mi']}[/code][/spoiler]\n\n") + + desc = base + desc = bbcode.convert_pre_to_code(desc) + desc = bbcode.convert_hide_to_spoiler(desc) + desc = bbcode.convert_comparison_to_collapse(desc, 1000) try: - - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - open_torrent.close() - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + tonemapped_header = self.config['DEFAULT'].get('tonemapped_header') + desc = desc + tonemapped_header + desc = desc + "\n\n" + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + desc = desc.replace('[img]', '[img=300]') + await descfile.write(desc) + if f'{self.tracker}_images_key' in meta: + images = meta[f'{self.tracker}_images_key'] + else: + images = meta['image_list'] + if len(images) > 0: + await descfile.write("[center]") + for each in range(len(images[:int(meta['screens'])])): + web_url = images[each]['web_url'] + raw_url = images[each]['raw_url'] + await descfile.write(f"[url={web_url}][img=350]{raw_url}[/img][/url]") + await descfile.write("[/center]") + + if self.signature is not None: + await descfile.write('\n' + self.signature) + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8') as f: + desc = await f.read() + return {'description': desc} - async def edit_name(self, meta): + async def get_name(self, meta): oe_name = meta.get('name') - return oe_name + resolution = meta.get('resolution') + video_encode = meta.get('video_encode') + name_type = meta.get('type', "") + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + imdb_name = meta.get('imdb_info', {}).get('title', "") + title = meta.get('title', "") + oe_name = oe_name.replace(f"{title}", imdb_name, 1) + year = str(meta.get('year', "")) + imdb_year = str(meta.get('imdb_info', {}).get('year', "")) + scale = "DS4K" if "DS4K" in meta['uuid'].upper() else "RM4K" if "RM4K" in meta['uuid'].upper() else "" + if not meta.get('category') == "TV": + oe_name = oe_name.replace(f"{year}", imdb_year, 1) + + if name_type == "DVDRIP": + if meta.get('category') == "MOVIE": + oe_name = oe_name.replace(f"{meta['source']}{meta['video_encode']}", f"{resolution}", 1) + oe_name = oe_name.replace((meta['audio']), f"{meta['audio']}{video_encode}", 1) + else: + oe_name = oe_name.replace(f"{meta['source']}", f"{resolution}", 1) + oe_name = oe_name.replace(f"{meta['video_codec']}", f"{meta['audio']} {meta['video_codec']}", 1) - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id + if not meta.get('audio_languages'): + await process_desc_language(meta, desc=None, tracker=self.tracker) + elif meta.get('audio_languages'): + audio_languages = meta['audio_languages'][0].upper() + if audio_languages and not await has_english_language(audio_languages) and not meta.get('is_disc') == "BDMV": + oe_name = oe_name.replace(meta['resolution'], f"{audio_languages} {meta['resolution']}", 1) + + if name_type in ["ENCODE", "WEBDL", "WEBRIP"] and scale != "": + oe_name = oe_name.replace(f"{resolution}", f"{scale}", 1) + + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + oe_name = re.sub(f"-{invalid_tag}", "", oe_name, flags=re.IGNORECASE) + oe_name = f"{oe_name}-NOGRP" + + return {'name': oe_name} + + async def get_type_id(self, meta): + video_codec = meta.get('video_codec', 'N/A') + + meta_type = meta['type'] + if meta_type == "DVDRIP": + meta_type = "ENCODE" - async def get_type_id(self, type, tv_pack, video_codec, category): type_id = { - 'DISC': '19', + 'DISC': '19', 'REMUX': '20', 'WEBDL': '21', - }.get(type, '0') - if type == "WEBRIP": + }.get(meta_type, '0') + if meta_type == "WEBRIP": if video_codec == "HEVC": # x265 Encode type_id = '10' @@ -132,7 +190,7 @@ async def get_type_id(self, type, tv_pack, video_codec, category): if video_codec == 'AVC': # x264 Encode type_id = '15' - if type == "ENCODE": + if meta_type == "ENCODE": if video_codec == "HEVC": # x265 Encode type_id = '10' @@ -142,51 +200,4 @@ async def get_type_id(self, type, tv_pack, video_codec, category): if video_codec == 'AVC': # x264 Encode type_id = '15' - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type'], meta.get('tv_pack', 0), meta.get('sd', 0), meta.get('category', "")), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = f"{meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] + meta['edition'] - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes \ No newline at end of file + return {'type_id': type_id} diff --git a/src/trackers/OTW.py b/src/trackers/OTW.py new file mode 100644 index 000000000..118510bc2 --- /dev/null +++ b/src/trackers/OTW.py @@ -0,0 +1,122 @@ +# -*- coding: utf-8 -*- +import cli_ui +from src.trackers.COMMON import COMMON +from src.console import console +from src.trackers.UNIT3D import UNIT3D + + +class OTW(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='OTW') + self.config = config + self.common = COMMON(config) + self.tracker = 'OTW' + self.source_flag = 'OTW' + self.base_url = 'https://oldtoons.world' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + '[Oj]', '3LTON', '4yEo', 'ADE', 'AFG', 'AniHLS', 'AnimeRG', 'AniURL', + 'AROMA', 'aXXo', 'CM8', 'CrEwSaDe', 'DeadFish', 'DNL', 'ELiTE', + 'eSc', 'FaNGDiNG0', 'FGT', 'Flights', 'FRDS', 'FUM', 'GalaxyRG', 'HAiKU', + 'HD2DVD', 'HDS', 'HDTime', 'Hi10', 'INFINITY', 'ION10', 'iPlanet', 'JIVE', 'KiNGDOM', + 'LAMA', 'Leffe', 'LOAD', 'mHD', 'NhaNc3', 'nHD', 'NOIVTC', 'nSD', 'PiRaTeS', + 'PRODJi', 'RAPiDCOWS', 'RARBG', 'RDN', 'REsuRRecTioN', 'RMTeam', 'SANTi', + 'SicFoI', 'SPASM', 'STUTTERSHIT', 'Telly', 'TM', 'UPiNSMOKE', 'WAF', 'xRed', + 'XS', 'YELLO', 'YIFY', 'YTS', 'ZKBL', 'ZmN', '4f8c4100292', 'Azkars', 'Sync0rdi', + ['EVO', 'Raw Content Only'], ['TERMiNAL', 'Raw Content Only'], + ['ViSION', 'Note the capitalization and characters used'], ['CMRG', 'Raw Content Only'] + ] + pass + + async def get_additional_checks(self, meta): + should_continue = True + + if not any(genre in meta['genres'] for genre in ['Animation', 'Family']): + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print('[bold red]Genre does not match Animation or Family for OTW.') + if cli_ui.ask_yes_no("Do you want to upload anyway?", default=False): + pass + else: + should_continue = False + else: + should_continue = False + disallowed_keywords = {'XXX', 'Erotic', 'Porn', 'Hentai', 'Adult Animation', 'Orgy', 'softcore'} + if any(keyword.lower() in disallowed_keywords for keyword in map(str.lower, meta['keywords'])): + if not meta['unattended']: + console.print('[bold red]Adult animation not allowed at OTW.') + should_continue = False + + return should_continue + + async def get_type_id(self, meta, type=None, reverse=False, mapping_only=False): + type = meta['type'] + if meta.get('is_disc') == 'BDMV': + return {'type_id': '1'} + elif meta.get('is_disc') and meta.get('is_disc') != 'BDMV': + return {'type_id': '7'} + if type == "DVDRIP": + return {'type_id': '8'} + type_id = { + 'DISC': '1', + 'REMUX': '2', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6', + 'ENCODE': '3' + } + if mapping_only: + return type_id + elif reverse: + return {v: k for k, v in type_id.items()} + elif type is not None: + return {'type_id': type_id.get(type, '0')} + else: + meta_type = meta.get('type', '') + resolved_id = type_id.get(meta_type, '0') + return {'type_id': resolved_id} + + async def get_name(self, meta): + otw_name = meta['name'] + source = meta['source'] + resolution = meta['resolution'] + aka = meta.get('aka', '') + type = meta['type'] + if aka: + otw_name = otw_name.replace(meta["aka"], '') + if meta['is_disc'] == "DVD": + otw_name = otw_name.replace(source, f"{source} {resolution}") + if meta['is_disc'] == "DVD" or type == "REMUX": + otw_name = otw_name.replace(meta['audio'], f"{meta.get('video_codec', '')} {meta['audio']}", 1) + elif meta['is_disc'] == "DVD" or (type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD")): + otw_name = otw_name.replace((meta['source']), f"{resolution} {meta['source']}", 1) + if meta['category'] == "TV": + years = [] + + tmdb_year = meta.get('year') + if tmdb_year and str(tmdb_year).isdigit(): + years.append(int(tmdb_year)) + + imdb_year = meta.get('imdb_info', {}).get('year') + if imdb_year and str(imdb_year).isdigit(): + years.append(int(imdb_year)) + + series_year = meta.get('tvdb_episode_data', {}).get('series_year') + if series_year and str(series_year).isdigit(): + years.append(int(series_year)) + # Use the oldest year if any found, else empty string + year = str(min(years)) if years else "" + if not meta.get('no_year', False) and not meta.get('search_year', ''): + otw_name = otw_name.replace(meta['title'], f"{meta['title']} {year}", 1) + + return {'name': meta['name']} + + async def get_additional_data(self, meta): + data = { + 'mod_queue_opt_in': await self.get_flag(meta, 'modq'), + } + + return data diff --git a/src/trackers/PHD.py b/src/trackers/PHD.py new file mode 100644 index 000000000..058c99da9 --- /dev/null +++ b/src/trackers/PHD.py @@ -0,0 +1,349 @@ +# -*- coding: utf-8 -*- +from datetime import datetime +from src.trackers.COMMON import COMMON +from src.trackers.AVISTAZ_NETWORK import AZTrackerBase + + +class PHD(AZTrackerBase): + def __init__(self, config): + super().__init__(config, tracker_name='PHD') + self.config = config + self.common = COMMON(config) + self.tracker = 'PHD' + self.source_flag = 'PrivateHD' + self.banned_groups = [''] + self.base_url = 'https://privatehd.to' + self.torrent_url = f'{self.base_url}/torrent/' + + async def rules(self, meta): + warnings = [] + + is_bd_disc = False + if meta.get('is_disc', '') == 'BDMV': + is_bd_disc = True + + video_codec = meta.get('video_codec', '') + if video_codec: + video_codec = video_codec.strip().lower() + + video_encode = meta.get('video_encode', '') + if video_encode: + video_encode = video_encode.strip().lower() + + type = meta.get('type', '') + if type: + type = type.strip().lower() + + source = meta.get('source', '') + if source: + source = source.strip().lower() + + image_links = [img.get('raw_url') for img in meta.get('image_list', []) if img.get('raw_url')] + if len(image_links) < 3: + warnings.append(f'{self.tracker}: At least 3 screenshots are required to upload.') + + # This also checks the rule 'FANRES content is not allowed' + if meta['category'] not in ('MOVIE', 'TV'): + warnings.append( + 'The only allowed content to be uploaded are Movies and TV Shows.\n' + 'Anything else, like games, music, software and porn is not allowed!' + ) + + if meta.get('anime', False): + warnings.append("Upload Anime content to our sister site AnimeTorrents.me instead. If it's on AniDB, it's an anime.") + + year = meta.get('year') + current_year = datetime.now().year + is_older_than_50_years = (current_year - year) >= 50 + if is_older_than_50_years: + warnings.append('Upload movies/series 50+ years old to our sister site CinemaZ.to instead.') + + # https://en.wikipedia.org/wiki/List_of_ISO_3166_country_codes + + africa = [ + 'AO', 'BF', 'BI', 'BJ', 'BW', 'CD', 'CF', 'CG', 'CI', 'CM', 'CV', 'DJ', 'DZ', 'EG', 'EH', + 'ER', 'ET', 'GA', 'GH', 'GM', 'GN', 'GQ', 'GW', 'IO', 'KE', 'KM', 'LR', 'LS', 'LY', 'MA', + 'MG', 'ML', 'MR', 'MU', 'MW', 'MZ', 'NA', 'NE', 'NG', 'RE', 'RW', 'SC', 'SD', 'SH', 'SL', + 'SN', 'SO', 'SS', 'ST', 'SZ', 'TD', 'TF', 'TG', 'TN', 'TZ', 'UG', 'YT', 'ZA', 'ZM', 'ZW' + ] + + america = [ + 'AG', 'AI', 'AR', 'AW', 'BB', 'BL', 'BM', 'BO', 'BQ', 'BR', 'BS', 'BV', 'BZ', 'CA', 'CL', + 'CO', 'CR', 'CU', 'CW', 'DM', 'DO', 'EC', 'FK', 'GD', 'GF', 'GL', 'GP', 'GS', 'GT', 'GY', + 'HN', 'HT', 'JM', 'KN', 'KY', 'LC', 'MF', 'MQ', 'MS', 'MX', 'NI', 'PA', 'PE', 'PM', 'PR', + 'PY', 'SR', 'SV', 'SX', 'TC', 'TT', 'US', 'UY', 'VC', 'VE', 'VG', 'VI' + ] + + asia = [ + 'AE', 'AF', 'AM', 'AZ', 'BD', 'BH', 'BN', 'BT', 'CN', 'CY', 'GE', 'HK', 'ID', 'IL', 'IN', + 'IQ', 'IR', 'JO', 'JP', 'KG', 'KH', 'KP', 'KR', 'KW', 'KZ', 'LA', 'LB', 'LK', 'MM', 'MN', + 'MO', 'MV', 'MY', 'NP', 'OM', 'PH', 'PK', 'PS', 'QA', 'SA', 'SG', 'SY', 'TH', 'TJ', 'TL', + 'TM', 'TR', 'TW', 'UZ', 'VN', 'YE' + ] + + europe = [ + 'AD', 'AL', 'AT', 'AX', 'BA', 'BE', 'BG', 'BY', 'CH', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', + 'FO', 'FR', 'GB', 'GG', 'GI', 'GR', 'HR', 'HU', 'IE', 'IM', 'IS', 'IT', 'JE', 'LI', 'LT', + 'LU', 'LV', 'MC', 'MD', 'ME', 'MK', 'MT', 'NL', 'NO', 'PL', 'PT', 'RO', 'RS', 'RU', 'SE', + 'SI', 'SJ', 'SK', 'SM', 'SU', 'UA', 'VA', 'XC' + ] + + oceania = [ + 'AS', 'AU', 'CC', 'CK', 'CX', 'FJ', 'FM', 'GU', 'HM', 'KI', 'MH', 'MP', 'NC', 'NF', 'NR', + 'NU', 'NZ', 'PF', 'PG', 'PN', 'PW', 'SB', 'TK', 'TO', 'TV', 'UM', 'VU', 'WF', 'WS' + ] + + phd_allowed_countries = [ + 'AG', 'AI', 'AU', 'BB', 'BM', 'BS', 'BZ', 'CA', 'CW', 'DM', 'GB', 'GD', 'IE', + 'JM', 'KN', 'KY', 'LC', 'MS', 'NZ', 'PR', 'TC', 'TT', 'US', 'VC', 'VG', 'VI', + ] + + all_countries = africa + america + europe + oceania + cinemaz_countries = list(set(all_countries) - set(phd_allowed_countries)) + + origin_countries_codes = meta.get('origin_country', []) + + if any(code in phd_allowed_countries for code in origin_countries_codes): + pass + + # CinemaZ + elif any(code in cinemaz_countries for code in origin_countries_codes): + warnings.append('Upload European (EXCLUDING United Kingdom and Ireland), South American and African content to our sister site CinemaZ.to instead.') + + # AvistaZ + elif any(code in asia for code in origin_countries_codes): + origin_country_str = ', '.join(origin_countries_codes) + warnings.append( + 'DO NOT upload content originating from countries shown in this map (https://imgur.com/nIB9PM1).\n' + 'In case of doubt, message the staff first. Upload Asian content to our sister site Avistaz.to instead.\n' + f'Origin country for your upload: {origin_country_str}' + ) + + elif not any(code in phd_allowed_countries for code in origin_countries_codes): + warnings.append( + 'Only upload content to PrivateHD from all major English speaking countries.\n' + 'Including United States, Canada, UK, Ireland, Australia, and New Zealand.' + ) + + # Tags + tag = meta.get('tag', '') + if tag: + tag = tag.strip().lower() + if tag in ('rarbg', 'fgt', 'grym', 'tbs'): + warnings.append('Do not upload RARBG, FGT, Grym or TBS. Existing uploads by these groups can be trumped at any time.') + + if tag == 'evo' and source != 'web': + warnings.append('Do not upload non-web EVO releases. Existing uploads by this group can be trumped at any time.') + + if meta.get('sd', '') == 1: + warnings.append('SD (Standard Definition) content is forbidden.') + + if not is_bd_disc: + if meta.get('container') not in ['mkv', 'mp4']: + warnings.append('Allowed containers: MKV, MP4.') + + # Video codec + # 1 + if type == 'remux': + if video_codec not in ('mpeg-2', 'vc-1', 'h.264', 'h.265', 'avc'): + warnings.append('Allowed Video Codecs for BluRay (Untouched + REMUX): MPEG-2, VC-1, H.264, H.265') + + # 2 + if type == 'encode' and source == 'bluray': + if video_encode not in ('h.264', 'h.265', 'x264', 'x265'): + warnings.append('Allowed Video Codecs for BluRay (Encoded): H.264, H.265 (x264 and x265 respectively are the only permitted encoders)') + + # 3 + if type in ('webdl', 'web-dl') and source == 'web': + if video_encode not in ('h.264', 'h.265', 'vp9'): + warnings.append('Allowed Video Codecs for WEB (Untouched): H.264, H.265, VP9') + + # 4 + if type == 'encode' and source == 'web': + if video_encode not in ('h.264', 'h.265', 'x264', 'x265'): + warnings.append('Allowed Video Codecs for WEB (Encoded): H.264, H.265 (x264 and x265 respectively are the only permitted encoders)') + + # 5 + if type == 'encode': + if video_encode == 'x265': + if meta.get('bit_depth', '') != '10': + warnings.append('Allowed Video Codecs for x265 encodes must be 10-bit') + + # 6 + resolution = int(meta.get('resolution').lower().replace('p', '').replace('i', '')) + if resolution > 1080: + if video_encode in ('h.264', 'x264'): + warnings.append('H.264/x264 only allowed for 1080p and below.') + + # 7 + if video_codec not in ('avc', 'mpeg-2', 'vc-1', 'avc', 'h.264', 'vp9', 'h.265', 'x264', 'x265', 'hevc'): + warnings.append(f'Video codec not allowed in your upload: {video_codec}.') + + # Audio codec + if is_bd_disc: + pass + else: + # 1 + allowed_keywords = ['AC3', 'Dolby Digital', 'Dolby TrueHD', 'DTS', 'DTS-HD', 'FLAC', 'AAC', 'Dolby'] + + # 2 + forbidden_keywords = ['LPCM', 'PCM', 'Linear PCM'] + + audio_tracks = [] + media_tracks = meta.get('mediainfo', {}).get('media', {}).get('track', []) + for track in media_tracks: + if track.get('@type') == 'Audio': + codec_info = track.get('Format_Commercial_IfAny') + codec = codec_info if isinstance(codec_info, str) else '' + audio_tracks.append({ + 'codec': codec, + 'language': track.get('Language', '') + }) + + # 3 + original_language = meta.get('original_language', '') + language_track = track.get('language', '') + if original_language and language_track: + # Filter to only have audio tracks that are in the original language + original_language_tracks = [ + track for track in audio_tracks if track.get('language', '').lower() == original_language.lower() + ] + + # Now checks are only done on the original language track list + if original_language_tracks: + has_truehd_atmos = any( + 'truehd' in track['codec'].lower() and 'atmos' in track['codec'].lower() + for track in original_language_tracks + ) + + # Check if there is an AC-3 compatibility track in the same language + has_ac3_compat_track = any( + 'ac-3' in track['codec'].lower() or 'dolby digital' in track['codec'].lower() + for track in original_language_tracks + ) + + if has_truehd_atmos and not has_ac3_compat_track: + warnings.append( + f'A TrueHD Atmos track was detected in the original language ({original_language}), ' + f'but no AC-3 (Dolby Digital) compatibility track was found for that same language.\n' + 'Rule: TrueHD/Atmos audio must have a compatibility track due to poor compatibility with most players.' + ) + + # 4 + invalid_codecs = [] + for track in audio_tracks: + codec = track['codec'] + if not codec: + continue + + is_forbidden = any(kw.lower() in codec.lower() for kw in forbidden_keywords) + if is_forbidden: + invalid_codecs.append(codec) + continue + + is_allowed = any(kw.lower() in codec.lower() for kw in allowed_keywords) + if not is_allowed: + invalid_codecs.append(codec) + + if invalid_codecs: + unique_invalid_codecs = sorted(list(set(invalid_codecs))) + warnings.append( + f"Unallowed audio codec(s) detected: {', '.join(unique_invalid_codecs)}\n" + f'Allowed codecs: AC3 (Dolby Digital), Dolby TrueHD, DTS, DTS-HD (MA), FLAC, AAC, all other Dolby codecs.\n' + f'Dolby Exceptions: Any uncompressed audio codec that comes on a BluRay disc like; PCM, LPCM, etc.' + ) + + # Quality check + BITRATE_RULES = { + ('x265', 'web', 720): 1500000, + ('x265', 'web', 1080): 2500000, + ('x265', 'bluray', 720): 2000000, + ('x265', 'bluray', 1080): 3500000, + + ('x264', 'web', 720): 2500000, + ('x264', 'web', 1080): 4500000, + ('x264', 'bluray', 720): 3500000, + ('x264', 'bluray', 1080): 6000000, + } + + WEB_SOURCES = ('hdtv', 'web', 'hdrip') + + if type == 'encode': + bitrate = 0 + for track in media_tracks: + if track.get('@type') == 'Video': + bitrate = int(track.get('BitRate')) + break + + source_type = None + if source in WEB_SOURCES: + source_type = 'web' + elif source == 'bluray': + source_type = 'bluray' + + if source_type: + rule_key = (video_encode, source_type, resolution) + + if rule_key in BITRATE_RULES: + min_bitrate = BITRATE_RULES[rule_key] + + if bitrate < min_bitrate: + quality_rule_text = ( + 'Only upload proper encodes.\n' + 'Any encodes where the size and/or the bitrate imply a bad quality will be deleted.' + ) + rule = ( + f'Your upload was rejected due to low quality.\n' + f'Minimum bitrate for {resolution}p {source.upper()} {video_encode.upper()} is {min_bitrate / 1000} Kbps.' + ) + warnings.append(quality_rule_text + rule) + + if resolution < 720: + rule = 'Video must be at least 720p.' + warnings.append(rule) + + # Hybrid + if type in ('remux', 'encode'): + if 'hybrid' in meta.get('name', '').lower(): + warnings.append( + 'Hybrid Remuxes and Encodes are subject to the following condition:\n\n' + 'Hybrid user releases are permitted, but are treated similarly to regular ' + 'user releases and must be approved by staff before you upload them ' + '(please see the torrent approvals forum for details).' + ) + + # Log + if type == 'remux': + warnings.append( + 'Remuxes must have a demux/eac3to log under spoilers in description.\n' + 'Do you have these logs and will you add them to the description after upload?' + ) + + # Bloated + if meta.get('bloated', False): + warnings.append( + 'Audio dubs are never preferred and can always be trumped by original audio only rip (Exception for BD50/BD25).\n' + 'Do NOT upload a multi audio release when there is already a original audio only release on site.\n' + ) + + if warnings: + all_warnings = '\n\n'.join(filter(None, warnings)) + return all_warnings + + return + + def get_rip_type(self, meta): + source_type = meta.get('type') + + keyword_map = { + 'bdrip': '1', + 'encode': '2', + 'disc': '3', + 'hdrip': '6', + 'hdtv': '7', + 'webdl': '12', + 'webrip': '13', + 'remux': '14', + } + + return keyword_map.get(source_type.lower()) diff --git a/src/trackers/PT.py b/src/trackers/PT.py new file mode 100644 index 000000000..ef43b2ca2 --- /dev/null +++ b/src/trackers/PT.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +import os +import re +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class PT(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='PT') + self.config = config + self.common = COMMON(config) + self.tracker = 'PT' + self.source_flag = 'Portugas' + self.base_url = 'https://portugas.org' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_type_id(self, meta): + type_id = { + 'DISC': '1', + 'REMUX': '2', + 'WEBDL': '4', + 'WEBRIP': '39', + 'HDTV': '6', + 'ENCODE': '3' + }.get(meta['type'], '0') + return {'type_id': type_id} + + async def get_resolution_id(self, meta): + resolution_id = { + '4320p': '1', + '2160p': '2', + '1440p': '13', + '1080p': '3', + '1080i': '4', + '720p': '5', + '576p': '6', + '576i': '7', + '540p': '11', + '480p': '8', + '480i': '9' + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} + + async def get_name(self, meta): + name = meta['name'].replace(' ', '.') + + pt_name = name + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + pt_name = re.sub(f"-{invalid_tag}", "", pt_name, flags=re.IGNORECASE) + pt_name = f"{pt_name}-NOGROUP" + + return {'name': pt_name} + + def get_audio(self, meta): + found_portuguese_audio = False + + if meta.get('is_disc') == "BDMV": + bdinfo = meta.get('bdinfo', {}) + audio_tracks = bdinfo.get("audio", []) + if audio_tracks: + for track in audio_tracks: + lang = track.get("language", "") + if lang and lang.lower() == "portuguese": + found_portuguese_audio = True + break + + needs_mediainfo_check = (meta.get('is_disc') != "BDMV") or (meta.get('is_disc') == "BDMV" and not found_portuguese_audio) + + if needs_mediainfo_check: + base_dir = meta.get('base_dir', '.') + uuid = meta.get('uuid', 'default_uuid') + media_info_path = os.path.join(base_dir, 'tmp', uuid, 'MEDIAINFO.txt') + + try: + if os.path.exists(media_info_path): + with open(media_info_path, 'r', encoding='utf-8') as f: + media_info_text = f.read() + + if not found_portuguese_audio: + audio_sections = re.findall(r'Audio(?: #\d+)?\s*\n(.*?)(?=\n\n(?:Audio|Video|Text|Menu)|$)', media_info_text, re.DOTALL | re.IGNORECASE) + for section in audio_sections: + language_match = re.search(r'Language\s*:\s*(.+)', section, re.IGNORECASE) + title_match = re.search(r'Title\s*:\s*(.+)', section, re.IGNORECASE) + + lang_raw = language_match.group(1).strip() if language_match else "" + title_raw = title_match.group(1).strip() if title_match else "" + + text = f'{lang_raw} {title_raw}'.lower() + + if "portuguese" in text and not any(keyword in text for keyword in ["(br)", "brazilian"]): + found_portuguese_audio = True + break + + except FileNotFoundError: + pass + except Exception as e: + print(f"ERRO: Falha ao processar MediaInfo para verificar áudio Português: {e}") + + return 1 if found_portuguese_audio else 0 + + def get_subtitles(self, meta): + found_portuguese_subtitle = False + + if meta.get('is_disc') == "BDMV": + bdinfo = meta.get('bdinfo', {}) + subtitle_tracks = bdinfo.get("subtitles", []) + if subtitle_tracks: + found_portuguese_subtitle = False + for track in subtitle_tracks: + if isinstance(track, str) and track.lower() == "portuguese": + found_portuguese_subtitle = True + break + + needs_mediainfo_check = (meta.get('is_disc') != "BDMV") or (meta.get('is_disc') == "BDMV" and not found_portuguese_subtitle) + + if needs_mediainfo_check: + base_dir = meta.get('base_dir', '.') + uuid = meta.get('uuid', 'default_uuid') + media_info_path = os.path.join(base_dir, 'tmp', uuid, 'MEDIAINFO.txt') + + try: + if os.path.exists(media_info_path): + with open(media_info_path, 'r', encoding='utf-8') as f: + media_info_text = f.read() + + if not found_portuguese_subtitle: + text_sections = re.findall(r'Text(?: #\d+)?\s*\n(.*?)(?=\n\n(?:Audio|Video|Text|Menu)|$)', media_info_text, re.DOTALL | re.IGNORECASE) + if not text_sections: + text_sections = re.findall(r'Subtitle(?: #\d+)?\s*\n(.*?)(?=\n\n(?:Audio|Video|Text|Menu)|$)', media_info_text, re.DOTALL | re.IGNORECASE) + + for section in text_sections: + language_match = re.search(r'Language\s*:\s*(.+)', section, re.IGNORECASE) + title_match = re.search(r'Title\s*:\s*(.+)', section, re.IGNORECASE) + + lang_raw = language_match.group(1).strip() if language_match else "" + title_raw = title_match.group(1).strip() if title_match else "" + + text = f'{lang_raw} {title_raw}'.lower() + + if "portuguese" in text and not any(keyword in text for keyword in ["(br)", "brazilian"]): + found_portuguese_subtitle = True + break + + except FileNotFoundError: + pass + except Exception as e: + print(f"ERRO: Falha ao processar MediaInfo para verificar legenda Português: {e}") + + return 1 if found_portuguese_subtitle else 0 + + async def get_distributor_ids(self, meta): + return {} + + async def get_region_id(self, meta): + return {} + + async def get_additional_data(self, meta): + audio_flag = self.get_audio(meta) + subtitle_flag = self.get_subtitles(meta) + + data = { + 'audio_pt': audio_flag, + 'legenda_pt': subtitle_flag, + } + + return data diff --git a/src/trackers/PTER.py b/src/trackers/PTER.py index b9fcecfa0..6fdb6ac3d 100644 --- a/src/trackers/PTER.py +++ b/src/trackers/PTER.py @@ -1,19 +1,16 @@ from bs4 import BeautifulSoup import requests -import asyncio import re import os from pathlib import Path -import traceback import json import glob -import distutils.util -import cli_ui import pickle +import httpx from unidecode import unidecode -from urllib.parse import urlparse, quote +from urllib.parse import urlparse from src.trackers.COMMON import COMMON -from src.exceptions import * +from src.exceptions import * # noqa E403 from src.console import console @@ -23,23 +20,23 @@ def __init__(self, config): self.config = config self.tracker = 'PTER' self.source_flag = 'PTER' - self.passkey = str(config['TRACKERS']['PTER'].get('passkey', '')).strip() + self.passkey = str(config['TRACKERS']['PTER'].get('passkey', '')).strip() self.username = config['TRACKERS']['PTER'].get('username', '').strip() self.password = config['TRACKERS']['PTER'].get('password', '').strip() self.rehost_images = config['TRACKERS']['PTER'].get('img_rehost', False) self.ptgen_api = config['TRACKERS']['PTER'].get('ptgen_api').strip() - self.ptgen_retry=3 + self.ptgen_retry = 3 self.signature = None self.banned_groups = [""] async def validate_credentials(self, meta): vcookie = await self.validate_cookies(meta) - if vcookie != True: + if vcookie is not True: console.print('[red]Failed to validate cookies. Please confirm that the site is up and your passkey is valid.') return False return True - + async def validate_cookies(self, meta): common = COMMON(config=self.config) url = "https://pterclub.com" @@ -48,12 +45,7 @@ async def validate_cookies(self, meta): with requests.Session() as session: session.cookies.update(await common.parseCookieFile(cookiefile)) resp = session.get(url=url) - - if meta['debug']: - console.print('[cyan]Cookies:') - console.print(session.cookies.get_dict()) - console.print("\n\n") - console.print(resp.text) + if resp.text.find("""""") != -1: return True else: @@ -61,57 +53,68 @@ async def validate_cookies(self, meta): else: console.print("[bold red]Missing Cookie File. (data/cookies/PTER.txt)") return False - - async def search_existing(self, meta): + + async def search_existing(self, meta, disctype): dupes = [] common = COMMON(config=self.config) cookiefile = f"{meta['base_dir']}/data/cookies/PTER.txt" - if os.path.exists(cookiefile): - with requests.Session() as session: - session.cookies.update(await common.parseCookieFile(cookiefile)) - if int(meta['imdb_id'].replace('tt', '')) != 0: - imdb = f"tt{meta['imdb_id']}" - else: - imdb = "" - source = await self.get_type_medium_id(meta) - search_url = f"https://pterclub.com/torrents.php?search={imdb}&incldead=0&search_mode=0&source{source}=1" - r = session.get(search_url) - soup = BeautifulSoup(r.text, 'lxml') - rows = soup.select('table.torrents > tr:has(table.torrentname)') - for row in rows: - text=row.select_one('a[href^="details.php?id="]') - if text != None: - release=text.attrs['title'] - if release: - dupes.append(release) - else: + if not os.path.exists(cookiefile): console.print("[bold red]Missing Cookie File. (data/cookies/PTER.txt)") return False + cookies = await common.parseCookieFile(cookiefile) + imdb = f"tt{meta['imdb']}" if int(meta['imdb_id']) != 0 else "" + source = await self.get_type_medium_id(meta) + search_url = f"https://pterclub.com/torrents.php?search={imdb}&incldead=0&search_mode=0&source{source}=1" + + try: + async with httpx.AsyncClient(cookies=cookies, timeout=10.0) as client: + response = await client.get(search_url) + + if response.status_code == 200: + soup = BeautifulSoup(response.text, 'lxml') + rows = soup.select('table.torrents > tr:has(table.torrentname)') + for row in rows: + text = row.select_one('a[href^="details.php?id="]') + if text is not None: + release = text.attrs.get('title', '') + if release: + dupes.append(release) + else: + console.print(f"[bold red]HTTP request failed. Status: {response.status_code}") + + except httpx.TimeoutException: + console.print("[bold red]Request timed out while searching for existing torrents.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + console.print_exception() + return dupes async def get_type_category_id(self, meta): cat_id = "EXIT" - + if meta['category'] == 'MOVIE': cat_id = 401 - + if meta['category'] == 'TV': cat_id = 404 - + if 'documentary' in meta.get("genres", "").lower() or 'documentary' in meta.get("keywords", "").lower(): cat_id = 402 - + if 'Animation' in meta.get("genres", "").lower() or 'Animation' in meta.get("keywords", "").lower(): cat_id = 403 - + return cat_id - + async def get_area_id(self, meta): - - area_id=8 - area_map = { #To do + + area_id = 8 + area_map = { # To do "中国大陆": 1, "中国香港": 2, "中国台湾": 3, "美国": 4, "日本": 6, "韩国": 5, - "印度": 7, "法国": 4, "意大利": 4, "德国": 4, "西班牙": 4, "葡萄牙": 4, + "印度": 7, "法国": 4, "意大利": 4, "德国": 4, "西班牙": 4, "葡萄牙": 4, "英国": 4, "阿根廷": 8, "澳大利亚": 4, "比利时": 4, "巴西": 8, "加拿大": 4, "瑞士": 4, "智利": 8, } @@ -120,25 +123,23 @@ async def get_area_id(self, meta): if area in regions: return area_map[area] return area_id - - async def get_type_medium_id(self, meta): medium_id = "EXIT" # 1 = UHD Discs if meta.get('is_disc', '') in ("BDMV", "HD DVD"): - if meta['resolution']=='2160p': + if meta['resolution'] == '2160p': medium_id = 1 else: - medium_id = 2 #BD Discs - + medium_id = 2 # BD Discs + if meta.get('is_disc', '') == "DVD": - medium_id = 7 - + medium_id = 7 + # 4 = HDTV if meta.get('type', '') == "HDTV": medium_id = 4 - + # 6 = Encode if meta.get('type', '') in ("ENCODE", "WEBRIP"): medium_id = 6 @@ -154,18 +155,17 @@ async def get_type_medium_id(self, meta): return medium_id async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as descfile: + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8').read() + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf-8') as descfile: from src.bbcode import BBCODE from src.trackers.COMMON import COMMON common = COMMON(config=self.config) - if int(meta.get('imdb_id', '0').replace('tt', '')) != 0: + if int(meta.get('imdb_id')) != 0: ptgen = await common.ptgen(meta, self.ptgen_api, self.ptgen_retry) if ptgen.strip() != '': - descfile.write(ptgen) + descfile.write(ptgen) - bbcode = BBCODE() if meta.get('discs', []) != []: discs = meta['discs'] @@ -187,35 +187,35 @@ async def edit_desc(self, meta): desc = bbcode.convert_spoiler_to_hide(desc) desc = bbcode.convert_comparison_to_centered(desc, 1000) desc = desc.replace('[img]', '[img]') - desc = re.sub("(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) + desc = re.sub(r"(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) descfile.write(desc) - - if self.rehost_images == True: + + if self.rehost_images is True: console.print("[green]Rehosting Images...") images = await self.pterimg_upload(meta) - if len(images) > 0: + if len(images) > 0: descfile.write("[center]") for each in range(len(images[:int(meta['screens'])])): web_url = images[each]['web_url'] img_url = images[each]['img_url'] descfile.write(f"[url={web_url}][img]{img_url}[/img][/url]") - descfile.write("[/center]") + descfile.write("[/center]") else: images = meta['image_list'] - if len(images) > 0: + if len(images) > 0: descfile.write("[center]") for each in range(len(images[:int(meta['screens'])])): web_url = images[each]['web_url'] img_url = images[each]['img_url'] descfile.write(f"[url={web_url}][img]{img_url}[/img][/url]") descfile.write("[/center]") - - if self.signature != None: + + if self.signature is not None: descfile.write("\n\n") descfile.write(self.signature) descfile.close() - async def get_auth_token(self,meta): + async def get_auth_token(self, meta): if not os.path.exists(f"{meta['base_dir']}/data/cookies"): Path(f"{meta['base_dir']}/data/cookies").mkdir(parents=True, exist_ok=True) cookiefile = f"{meta['base_dir']}/data/cookies/Pterimg.pickle" @@ -228,23 +228,23 @@ async def get_auth_token(self,meta): loggedIn = await self.validate_login(r) else: console.print("[yellow]Pterimg Cookies not found. Creating new session.") - if loggedIn == True: + if loggedIn is True: auth_token = re.search(r'auth_token.*?\"(\w+)\"', r.text).groups()[0] else: data = { - 'login-subject': self.username, - 'password': self.password, + 'login-subject': self.username, + 'password': self.password, 'keep-login': 1 } r = session.get("https://s3.pterclub.com") data['auth_token'] = re.search(r'auth_token.*?\"(\w+)\"', r.text).groups()[0] - loginresponse = session.post(url='https://s3.pterclub.com/login',data=data) + loginresponse = session.post(url='https://s3.pterclub.com/login', data=data) if not loginresponse.ok: - raise LoginException("Failed to login to Pterimg. ") + raise LoginException("Failed to login to Pterimg. ") # noqa #F405 auth_token = re.search(r'auth_token = *?\"(\w+)\"', loginresponse.text).groups()[0] with open(cookiefile, 'wb') as cf: pickle.dump(session.cookies, cf) - + return auth_token async def validate_login(self, response): @@ -256,14 +256,14 @@ async def validate_login(self, response): async def pterimg_upload(self, meta): images = glob.glob(f"{meta['base_dir']}/tmp/{meta['uuid']}/{meta['filename']}-*.png") - url='https://s3.pterclub.com' - image_list=[] + url = 'https://s3.pterclub.com' + image_list = [] data = { 'type': 'file', - 'action': 'upload', - 'nsfw': 0, + 'action': 'upload', + 'nsfw': 0, 'auth_token': await self.get_auth_token(meta) - } + } cookiefile = f"{meta['base_dir']}/data/cookies/Pterimg.pickle" with requests.Session() as session: if os.path.exists(cookiefile): @@ -278,22 +278,15 @@ async def pterimg_upload(self, meta): except json.decoder.JSONDecodeError: res = {} if not req.ok: - if res['error']['message'] in ('重复上传','Duplicated upload'): + if res['error']['message'] in ('重复上传', 'Duplicated upload'): continue - raise(f'HTTP {req.status_code}, reason: {res["error"]["message"]}') + raise (f'HTTP {req.status_code}, reason: {res["error"]["message"]}') image_dict = {} image_dict['web_url'] = res['image']['url'] image_dict['img_url'] = res['image']['url'] - image_list.append(image_dict) + image_list.append(image_dict) return image_list - async def get_anon(self, anon): - if anon == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 'no' - else: - anon = 'yes' - return anon - async def edit_name(self, meta): pter_name = meta['name'] @@ -304,11 +297,11 @@ async def edit_name(self, meta): pter_name = pter_name.replace(meta["aka"], '') pter_name = pter_name.replace('PQ10', 'HDR') - if meta['type'] == 'WEBDL' and meta.get('has_encode_settings', False) == True: + if meta['type'] == 'WEBDL' and meta.get('has_encode_settings', False) is True: pter_name = pter_name.replace('H.264', 'x264') return pter_name - + async def is_zhongzi(self, meta): if meta.get('is_disc', '') != 'BDMV': mi = meta['mediainfo'] @@ -316,70 +309,76 @@ async def is_zhongzi(self, meta): if track['@type'] == "Text": language = track.get('Language') if language == "zh": - return 'yes' + return 'yes' else: for language in meta['bdinfo']['subtitles']: if language == "Chinese": - return 'yes' + return 'yes' return None - async def upload(self, meta): + async def upload(self, meta, disctype): common = COMMON(config=self.config) await common.edit_torrent(meta, self.tracker, self.source_flag) - desc_file=f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + desc_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" if not os.path.exists(desc_file): await self.edit_desc(meta) - + + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): + anon = 'no' + else: + anon = 'yes' + pter_name = await self.edit_name(meta) - - if meta['bdinfo'] != None: + + if meta['bdinfo'] is not None: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') else: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') pter_desc = open(desc_file, 'r').read() - torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent" - + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + with open(torrent_path, 'rb') as torrentFile: if len(meta['filelist']) == 1: torrentFileName = unidecode(os.path.basename(meta['video']).replace(' ', '.')) else: torrentFileName = unidecode(os.path.basename(meta['path']).replace(' ', '.')) files = { - 'file' : (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent"), + 'file': (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent"), } - #use chinese small_descr + # use chinese small_descr if meta['ptgen']["trans_title"] != ['']: - small_descr='' + small_descr = '' for title_ in meta['ptgen']["trans_title"]: - small_descr+=f'{title_} / ' - small_descr+="| 类别:"+meta['ptgen']["genre"][0] - small_descr=small_descr.replace('/ |','|') + small_descr += f'{title_} / ' + small_descr += "| 类别:" + meta['ptgen']["genre"][0] + small_descr = small_descr.replace('/ |', '|') else: - small_descr=meta['title'] - data= { + small_descr = meta['title'] + data = { "name": pter_name, "small_descr": small_descr, "descr": pter_desc, "type": await self.get_type_category_id(meta), "source_sel": await self.get_type_medium_id(meta), "team_sel": await self.get_area_id(meta), - "uplver": await self.get_anon(meta['anon']), + "uplver": anon, "zhongzi": await self.is_zhongzi(meta) } - if meta.get('personalrelease', False) == True: - data["pr"] = "yes" + if meta.get('personalrelease', False) is True: + data["pr"] = "yes" url = "https://pterclub.com/takeupload.php" - + # Submit if meta['debug']: console.print(url) console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." else: cookiefile = f"{meta['base_dir']}/data/cookies/PTER.txt" if os.path.exists(cookiefile): @@ -388,15 +387,15 @@ async def upload(self, meta): up = session.post(url=url, data=data, files=files) torrentFile.close() mi_dump.close() - + if up.url.startswith("https://pterclub.com/details.php?id="): - console.print(f"[green]Uploaded to: [yellow]{up.url.replace('&uploaded=1','')}[/yellow][/green]") + console.print(f"[green]Uploaded to: [yellow]{up.url.replace('&uploaded=1', '')}[/yellow][/green]") id = re.search(r"(id=)(\d+)", urlparse(up.url).query).group(2) await self.download_new_torrent(id, torrent_path) else: console.print(data) console.print("\n\n") - raise UploadException(f"Upload to Pter Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') + raise UploadException(f"Upload to Pter Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') # noqa #F405 return async def download_new_torrent(self, id, torrent_path): @@ -408,6 +407,3 @@ async def download_new_torrent(self, id, torrent_path): else: console.print("[red]There was an issue downloading the new .torrent from pter") console.print(r.text) - - - \ No newline at end of file diff --git a/src/trackers/PTP.py b/src/trackers/PTP.py index 01ec975fd..6ba27cae6 100644 --- a/src/trackers/PTP.py +++ b/src/trackers/PTP.py @@ -2,24 +2,25 @@ import requests import asyncio import re -import distutils.util import os from pathlib import Path -import time -import traceback import json import glob -import multiprocessing import platform import pickle +import click +import httpx from pymediainfo import MediaInfo - - from src.trackers.COMMON import COMMON from src.bbcode import BBCODE -from src.exceptions import * +from src.exceptions import * # noqa F403 from src.console import console - +from torf import Torrent +from cogs.redaction import redact_private_info +from datetime import datetime +from src.takescreens import disc_screenshots, dvd_screenshots, screenshots +from src.uploadscreens import upload_screens +from src.torrentcreate import CustomTorrent, torf_cb, create_torrent class PTP(): @@ -30,121 +31,145 @@ def __init__(self, config): self.source_flag = 'PTP' self.api_user = config['TRACKERS']['PTP'].get('ApiUser', '').strip() self.api_key = config['TRACKERS']['PTP'].get('ApiKey', '').strip() - self.announce_url = config['TRACKERS']['PTP'].get('announce_url', '').strip() - self.username = config['TRACKERS']['PTP'].get('username', '').strip() + announce_url = config['TRACKERS']['PTP'].get('announce_url', '').strip() + if announce_url: + self.announce_url = announce_url.replace('http://', 'https://') if announce_url.startswith('http://') else announce_url + self.username = config['TRACKERS']['PTP'].get('username', '').strip() self.password = config['TRACKERS']['PTP'].get('password', '').strip() - self.web_source = distutils.util.strtobool(str(config['TRACKERS']['PTP'].get('add_web_source_to_desc', True))) - self.user_agent = f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - self.banned_groups = ['aXXo', 'BRrip', 'CM8', 'CrEwSaDe', 'CTFOH', 'DNL', 'FaNGDiNG0', 'HD2DVD', 'HDTime', 'ION10', 'iPlanet', 'KiNGDOM', 'mHD', 'mSD', 'nHD', 'nikt0', 'nSD', 'NhaNc3', 'OFT', 'PRODJi', 'SANTi', 'STUTTERSHIT', 'ViSION', 'VXT', 'WAF', 'd3g', 'x0r', 'YIFY', 'BMDru'] - + self.web_source = self._is_true(config['TRACKERS']['PTP'].get('add_web_source_to_desc', True)) + self.user_agent = f'Upload Assistant/2.3 ({platform.system()} {platform.release()})' + self.banned_groups = ['aXXo', 'BMDru', 'BRrip', 'CM8', 'CrEwSaDe', 'CTFOH', 'd3g', 'DNL', 'FaNGDiNG0', 'HD2DVD', 'HDTime', 'ION10', 'iPlanet', + 'KiNGDOM', 'mHD', 'mSD', 'nHD', 'nikt0', 'nSD', 'NhaNc3', 'OFT', 'PRODJi', 'SANTi', 'SPiRiT', 'STUTTERSHIT', 'ViSION', 'VXT', + 'WAF', 'x0r', 'YIFY', 'LAMA', 'WORLD'] + self.sub_lang_map = { - ("Arabic", "ara", "ar") : 22, - ("Brazilian Portuguese", "Brazilian", "Portuguese-BR", 'pt-br') : 49, - ("Bulgarian", "bul", "bg") : 29, - ("Chinese", "chi", "zh", "Chinese (Simplified)", "Chinese (Traditional)") : 14, - ("Croatian", "hrv", "hr", "scr") : 23, - ("Czech", "cze", "cz", "cs") : 30, - ("Danish", "dan", "da") : 10, - ("Dutch", "dut", "nl") : 9, - ("English", "eng", "en", "English (CC)", "English - SDH") : 3, - ("English - Forced", "English (Forced)", "en (Forced)") : 50, - ("English Intertitles", "English (Intertitles)", "English - Intertitles", "en (Intertitles)") : 51, - ("Estonian", "est", "et") : 38, - ("Finnish", "fin", "fi") : 15, - ("French", "fre", "fr") : 5, - ("German", "ger", "de") : 6, - ("Greek", "gre", "el") : 26, - ("Hebrew", "heb", "he") : 40, - ("Hindi" "hin", "hi") : 41, - ("Hungarian", "hun", "hu") : 24, - ("Icelandic", "ice", "is") : 28, - ("Indonesian", "ind", "id") : 47, - ("Italian", "ita", "it") : 16, - ("Japanese", "jpn", "ja") : 8, - ("Korean", "kor", "ko") : 19, - ("Latvian", "lav", "lv") : 37, - ("Lithuanian", "lit", "lt") : 39, - ("Norwegian", "nor", "no") : 12, - ("Persian", "fa", "far") : 52, - ("Polish", "pol", "pl") : 17, - ("Portuguese", "por", "pt") : 21, - ("Romanian", "rum", "ro") : 13, - ("Russian", "rus", "ru") : 7, - ("Serbian", "srp", "sr", "scc") : 31, - ("Slovak", "slo", "sk") : 42, - ("Slovenian", "slv", "sl") : 43, - ("Spanish", "spa", "es") : 4, - ("Swedish", "swe", "sv") : 11, - ("Thai", "tha", "th") : 20, - ("Turkish", "tur", "tr") : 18, - ("Ukrainian", "ukr", "uk") : 34, - ("Vietnamese", "vie", "vi") : 25, + ("Arabic", "ara", "ar"): 22, + ("Brazilian Portuguese", "Brazilian", "Portuguese-BR", 'pt-br', 'pt-BR'): 49, + ("Bulgarian", "bul", "bg"): 29, + ("Chinese", "chi", "zh", "Chinese (Simplified)", "Chinese (Traditional)", 'cmn-Hant', 'cmn-Hans', 'yue-Hant', 'yue-Hans'): 14, + ("Croatian", "hrv", "hr", "scr"): 23, + ("Czech", "cze", "cz", "cs"): 30, + ("Danish", "dan", "da"): 10, + ("Dutch", "dut", "nl"): 9, + ("English", "eng", "en", "en-US", "en-GB", "English (CC)", "English - SDH"): 3, + ("English - Forced", "English (Forced)", "en (Forced)", "en-US (Forced)"): 50, + ("English Intertitles", "English (Intertitles)", "English - Intertitles", "en (Intertitles)", "en-US (Intertitles)"): 51, + ("Estonian", "est", "et"): 38, + ("Finnish", "fin", "fi"): 15, + ("French", "fre", "fr", "fr-FR", "fr-CA"): 5, + ("German", "ger", "de"): 6, + ("Greek", "gre", "el"): 26, + ("Hebrew", "heb", "he"): 40, + ("Hindi" "hin", "hi"): 41, + ("Hungarian", "hun", "hu"): 24, + ("Icelandic", "ice", "is"): 28, + ("Indonesian", "ind", "id"): 47, + ("Italian", "ita", "it"): 16, + ("Japanese", "jpn", "ja"): 8, + ("Korean", "kor", "ko"): 19, + ("Latvian", "lav", "lv"): 37, + ("Lithuanian", "lit", "lt"): 39, + ("Norwegian", "nor", "no"): 12, + ("Persian", "fa", "far"): 52, + ("Polish", "pol", "pl"): 17, + ("Portuguese", "por", "pt", "pt-PT"): 21, + ("Romanian", "rum", "ro"): 13, + ("Russian", "rus", "ru"): 7, + ("Serbian", "srp", "sr", "scc"): 31, + ("Slovak", "slo", "sk"): 42, + ("Slovenian", "slv", "sl"): 43, + ("Spanish", "spa", "es", "es-ES", "es-419"): 4, + ("Swedish", "swe", "sv"): 11, + ("Thai", "tha", "th"): 20, + ("Turkish", "tur", "tr"): 18, + ("Ukrainian", "ukr", "uk"): 34, + ("Vietnamese", "vie", "vi"): 25, } + def _is_true(self, value): + return str(value).strip().lower() in {"true", "1", "yes"} - - - async def get_ptp_id_imdb(self, search_term, search_file_folder): + async def get_ptp_id_imdb(self, search_term, search_file_folder, meta): imdb_id = ptp_torrent_id = None filename = str(os.path.basename(search_term)) params = { - 'filelist' : filename + 'filelist': filename } headers = { - 'ApiUser' : self.api_user, - 'ApiKey' : self.api_key, - 'User-Agent' : self.user_agent + 'ApiUser': self.api_user, + 'ApiKey': self.api_key, + 'User-Agent': self.user_agent } url = 'https://passthepopcorn.me/torrents.php' response = requests.get(url, params=params, headers=headers) await asyncio.sleep(1) console.print(f"[green]Searching PTP for: [bold yellow]{filename}[/bold yellow]") + try: if response.status_code == 200: response = response.json() + # console.print(f"[blue]Raw API Response: {response}[/blue]") + if int(response['TotalResults']) >= 1: for movie in response['Movies']: if len(movie['Torrents']) >= 1: for torrent in movie['Torrents']: - if search_file_folder == 'file': - for file in torrent['FileList']: - if file['Path'] == filename: - imdb_id = movie['ImdbId'] - ptp_torrent_id = torrent['Id'] - dummy, ptp_torrent_hash = await self.get_imdb_from_torrent_id(ptp_torrent_id) - console.print(f'[bold green]Matched release with PTP ID: [yellow]{ptp_torrent_id}[/yellow][/bold green]') - return imdb_id, ptp_torrent_id, ptp_torrent_hash - if search_file_folder == 'folder': - if str(torrent['FilePath']) == filename: - imdb_id = movie['ImdbId'] + # First, try matching in filelist > path + for file in torrent['FileList']: + if file.get('Path') == filename: + imdb_id = int(movie.get('ImdbId', 0) or 0) + imdb = f"tt{str(imdb_id).zfill(7)}" ptp_torrent_id = torrent['Id'] - dummy, ptp_torrent_hash = await self.get_imdb_from_torrent_id(ptp_torrent_id) + dummy, ptp_torrent_hash, *_ = await self.get_imdb_from_torrent_id(ptp_torrent_id) console.print(f'[bold green]Matched release with PTP ID: [yellow]{ptp_torrent_id}[/yellow][/bold green]') + + # Call get_torrent_info and print the results + tinfo = await self.get_torrent_info(imdb, meta) + console.print(f"[cyan]Torrent Info: {tinfo}[/cyan]") + return imdb_id, ptp_torrent_id, ptp_torrent_hash - else: - console.print(f'[yellow]Could not find any release matching [bold yellow]{filename}[/bold yellow] on PTP') - return None, None, None - elif int(response.status_code) in [400, 401, 403]: - console.print(f"[bold red]PTP: {response.text}") + + # If no match in filelist > path, check directly in filepath + if torrent.get('FilePath') == filename: + imdb_id = int(movie.get('ImdbId', 0) or 0) + ptp_torrent_id = torrent['Id'] + dummy, ptp_torrent_hash, *_ = await self.get_imdb_from_torrent_id(ptp_torrent_id) + console.print(f'[bold green]Matched release with PTP ID: [yellow]{ptp_torrent_id}[/yellow][/bold green]') + + # Call get_torrent_info and print the results + tinfo = await self.get_torrent_info(imdb_id, meta) + console.print(f"[cyan]Torrent Info: {tinfo}[/cyan]") + + return imdb_id, ptp_torrent_id, ptp_torrent_hash + + console.print(f'[yellow]Could not find any release matching [bold yellow]{filename}[/bold yellow] on PTP') return None, None, None - elif int(response.status_code) == 503: + + elif response.status_code in [400, 401, 403]: + console.print("[bold red]PTP Error: 400/401/403 - Invalid request or authentication failed[/bold red]") + return None, None, None + + elif response.status_code == 503: console.print("[bold yellow]PTP Unavailable (503)") return None, None, None + else: return None, None, None - except Exception: - pass + + except Exception as e: + console.print(f'[red]An error occurred: {str(e)}[/red]') + console.print(f'[yellow]Could not find any release matching [bold yellow]{filename}[/bold yellow] on PTP') return None, None, None - + async def get_imdb_from_torrent_id(self, ptp_torrent_id): params = { - 'torrentid' : ptp_torrent_id + 'torrentid': ptp_torrent_id } headers = { - 'ApiUser' : self.api_user, - 'ApiKey' : self.api_key, - 'User-Agent' : self.user_agent + 'ApiUser': self.api_user, + 'ApiKey': self.api_key, + 'User-Agent': self.user_agent } url = 'https://passthepopcorn.me/torrents.php' response = requests.get(url, params=params, headers=headers) @@ -152,7 +177,8 @@ async def get_imdb_from_torrent_id(self, ptp_torrent_id): try: if response.status_code == 200: response = response.json() - imdb_id = response['ImdbId'] + imdb_id = int(response.get('ImdbId', 0) or 0) + ptp_infohash = None for torrent in response['Torrents']: if torrent.get('Id', 0) == str(ptp_torrent_id): ptp_infohash = torrent.get('InfoHash', None) @@ -167,45 +193,79 @@ async def get_imdb_from_torrent_id(self, ptp_torrent_id): return None, None except Exception: return None, None - - async def get_ptp_description(self, ptp_torrent_id, is_disc): + + async def get_ptp_description(self, ptp_torrent_id, meta, is_disc): params = { - 'id' : ptp_torrent_id, - 'action' : 'get_description' + 'id': ptp_torrent_id, + 'action': 'get_description' } headers = { - 'ApiUser' : self.api_user, - 'ApiKey' : self.api_key, - 'User-Agent' : self.user_agent + 'ApiUser': self.api_user, + 'ApiKey': self.api_key, + 'User-Agent': self.user_agent } url = 'https://passthepopcorn.me/torrents.php' + console.print(f"[yellow]Requesting description from {url} with ID {ptp_torrent_id}") response = requests.get(url, params=params, headers=headers) await asyncio.sleep(1) + ptp_desc = response.text + # console.print(f"[yellow]Raw description received:\n{ptp_desc}...") # Show first 500 characters for brevity + desc = None + imagelist = [] bbcode = BBCODE() - desc = bbcode.clean_ptp_description(ptp_desc, is_disc) - console.print(f"[bold green]Successfully grabbed description from PTP") - return desc - + desc, imagelist = bbcode.clean_ptp_description(ptp_desc, is_disc) + + if not meta.get('only_id'): + console.print("[bold green]Successfully grabbed description from PTP") + console.print(f"Description after cleaning:\n{desc[:1000]}...", markup=False) # Show first 1000 characters for brevity + if not meta.get('skipit') and not meta['unattended']: + # Allow user to edit or discard the description + console.print("[cyan]Do you want to edit, discard or keep the description?[/cyan]") + edit_choice = input("Enter 'e' to edit, 'd' to discard, or press Enter to keep it as is: ") + + if edit_choice.lower() == 'e': + edited_description = click.edit(desc) + if edited_description: + desc = edited_description.strip() + meta['description'] = desc + meta['saved_description'] = True + console.print(f"[green]Final description after editing:[/green] {desc}") + elif edit_choice.lower() == 'd': + desc = None + console.print("[yellow]Description discarded.[/yellow]") + else: + console.print("[green]Keeping the original description.[/green]") + meta['description'] = desc + meta['saved_description'] = True + else: + meta['description'] = desc + meta['saved_description'] = True + if meta.get('keep_images'): + imagelist = imagelist + else: + imagelist = [] + + return imagelist async def get_group_by_imdb(self, imdb): params = { - 'imdb' : imdb, + 'imdb': imdb, } headers = { - 'ApiUser' : self.api_user, - 'ApiKey' : self.api_key, - 'User-Agent' : self.user_agent + 'ApiUser': self.api_user, + 'ApiKey': self.api_key, + 'User-Agent': self.user_agent } url = 'https://passthepopcorn.me/torrents.php' response = requests.get(url=url, headers=headers, params=params) await asyncio.sleep(1) try: response = response.json() - if response.get("Page") == "Browse": # No Releases on Site with ID + if response.get("Page") == "Browse": # No Releases on Site with ID return None - elif response.get('Page') == "Details": # Group Found + elif response.get('Page') == "Details": # Group Found groupID = response.get('GroupId') console.print(f"[green]Matched IMDb: [yellow]tt{imdb}[/yellow] to Group ID: [yellow]{groupID}[/yellow][/green]") console.print(f"[green]Title: [yellow]{response.get('Name')}[/yellow] ([yellow]{response.get('Year')}[/yellow])") @@ -215,17 +275,16 @@ async def get_group_by_imdb(self, imdb): console.print("[red]Please check that the site is online and your ApiUser/ApiKey values are correct") return None - async def get_torrent_info(self, imdb, meta): params = { - 'imdb' : imdb, - 'action' : 'torrent_info', - 'fast' : 1 + 'imdb': imdb, + 'action': 'torrent_info', + 'fast': 1 } headers = { - 'ApiUser' : self.api_user, - 'ApiKey' : self.api_key, - 'User-Agent' : self.user_agent + 'ApiUser': self.api_user, + 'ApiKey': self.api_key, + 'User-Agent': self.user_agent } url = "https://passthepopcorn.me/ajax.php" response = requests.get(url=url, params=params, headers=headers) @@ -233,6 +292,7 @@ async def get_torrent_info(self, imdb, meta): tinfo = {} try: response = response.json() + # console.print(f"[blue]Raw info API Response: {response}[/blue]") # title, plot, art, year, tags, Countries, Languages for key, value in response[0].items(): if value not in (None, ""): @@ -246,9 +306,9 @@ async def get_torrent_info(self, imdb, meta): async def get_torrent_info_tmdb(self, meta): tinfo = { - "title" : meta.get("title", ""), - "year" : meta.get("year", ""), - "album_desc" : meta.get("overview", ""), + "title": meta.get("title", ""), + "year": meta.get("year", ""), + "album_desc": meta.get("overview", ""), } tags = await self.get_tags([meta.get("genres", ""), meta.get("keywords", "")]) tinfo['tags'] = ", ".join(tags) @@ -262,55 +322,74 @@ async def get_tags(self, check_against): "history", "horror", "martial.arts", "musical", "mystery", "performance", "philosophy", "politics", "romance", "sci.fi", "short", "silent", "sport", "thriller", "video.art", "war", "western" ] + if not isinstance(check_against, list): check_against = [check_against] + normalized_check_against = [ + x.lower().replace(' ', '').replace('-', '') for x in check_against if isinstance(x, str) + ] for each in ptp_tags: - if any(each.replace('.', '') in x for x in check_against.lower().replace(' ', '').replace('-', '')): + clean_tag = each.replace('.', '') + if any(clean_tag in item for item in normalized_check_against): tags.append(each) + return tags - async def search_existing(self, groupID, meta): + async def search_existing(self, groupID, meta, disctype): # Map resolutions to SD / HD / UHD quality = None - if meta.get('sd', 0) == 1: # 1 is SD + if meta.get('sd', 0) == 1: # 1 is SD quality = "Standard Definition" elif meta['resolution'] in ["1440p", "1080p", "1080i", "720p"]: quality = "High Definition" elif meta['resolution'] in ["2160p", "4320p", "8640p"]: quality = "Ultra High Definition" - + # Prepare request parameters and headers params = { - 'id' : groupID, + 'id': groupID, } headers = { - 'ApiUser' : self.api_user, - 'ApiKey' : self.api_key, - 'User-Agent' : self.user_agent + 'ApiUser': self.api_user, + 'ApiKey': self.api_key, + 'User-Agent': self.user_agent } url = 'https://passthepopcorn.me/torrents.php' - response = requests.get(url=url, headers=headers, params=params) - await asyncio.sleep(1) - existing = [] + try: - response = response.json() - torrents = response.get('Torrents', []) - if len(torrents) != 0: - for torrent in torrents: - if torrent.get('Quality') == quality and quality != None: - existing.append(f"[{torrent.get('Resolution')}] {torrent.get('ReleaseName', 'RELEASE NAME NOT FOUND')}") - except Exception: - console.print("[red]An error has occured trying to find existing releases") - return existing + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url, headers=headers, params=params) + await asyncio.sleep(1) # Mimic server-friendly delay + if response.status_code == 200: + existing = [] + try: + data = response.json() + torrents = data.get('Torrents', []) + for torrent in torrents: + if torrent.get('Quality') == quality and quality is not None: + existing.append(f"[{torrent.get('Resolution')}] {torrent.get('ReleaseName', 'RELEASE NAME NOT FOUND')}") + except ValueError: + console.print("[red]Failed to parse JSON response from API.") + return existing + else: + console.print(f"[bold red]HTTP request failed with status code {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out while trying to find existing releases.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + console.print_exception() + return [] async def ptpimg_url_rehost(self, image_url): payload = { - 'format' : 'json', - 'api_key' : self.config["DEFAULT"]["ptpimg_api"], - 'link-upload' : image_url + 'format': 'json', + 'api_key': self.config["DEFAULT"]["ptpimg_api"], + 'link-upload': image_url } - headers = { 'referer': 'https://ptpimg.me/index.php'} + headers = {'referer': 'https://ptpimg.me/index.php'} url = "https://ptpimg.me/upload.php" response = requests.post(url, headers=headers, data=payload) @@ -319,18 +398,17 @@ async def ptpimg_url_rehost(self, image_url): ptpimg_code = response[0]['code'] ptpimg_ext = response[0]['ext'] img_url = f"https://ptpimg.me/{ptpimg_code}.{ptpimg_ext}" - except: + except Exception: console.print("[red]PTPIMG image rehost failed") img_url = image_url # img_url = ptpimg_upload(image_url, ptpimg_api) return img_url - def get_type(self, imdb_info, meta): ptpType = None if imdb_info['type'] is not None: imdbType = imdb_info.get('type', 'movie').lower() - if imdbType in ("movie", "tv movie"): + if imdbType in ("movie", "tv movie", 'tvmovie'): if int(imdb_info.get('runtime', '60')) >= 45 or int(imdb_info.get('runtime', '60')) == 0: ptpType = "Feature Film" else: @@ -342,7 +420,7 @@ def get_type(self, imdb_info, meta): elif imdbType == "comedy": ptpType = "Stand-up Comedy" elif imdbType == "concert": - ptpType = "Concert" + ptpType = "Live Performance" else: keywords = meta.get("keywords", "").lower() tmdb_type = meta.get("tmdb_type", "movie").lower() @@ -358,11 +436,13 @@ def get_type(self, imdb_info, meta): elif "stand-up comedy" in keywords: ptpType = "Stand-up Comedy" elif "concert" in keywords: - ptpType = "Concert" - if ptpType == None: + ptpType = "Live Performance" + if ptpType is None: if meta.get('mode', 'discord') == 'cli': ptpTypeList = ["Feature Film", "Short Film", "Miniseries", "Stand-up Comedy", "Concert", "Movie Collection"] ptpType = cli_ui.ask_choice("Select the proper type", choices=ptpTypeList) + if ptpType == "Concert": + ptpType = "Live Performance" return ptpType def get_codec(self, meta): @@ -380,14 +460,14 @@ def get_codec(self, meta): codec = "DVD9" else: codecmap = { - "AVC" : "H.264", - "H.264" : "H.264", - "HEVC" : "H.265", - "H.265" : "H.265", + "AVC": "H.264", + "H.264": "H.264", + "HEVC": "H.265", + "H.265": "H.265", } searchcodec = meta.get('video_codec', meta.get('video_encode')) codec = codecmap.get(searchcodec, searchcodec) - if meta.get('has_encode_settings') == True: + if meta.get('has_encode_settings') is True: codec = codec.replace("H.", "x") return codec @@ -411,29 +491,27 @@ def get_container(self, meta): else: ext = os.path.splitext(meta['filelist'][0])[1] containermap = { - '.mkv' : "MKV", - '.mp4' : 'MP4' + '.mkv': "MKV", + '.mp4': 'MP4' } container = containermap.get(ext, 'Other') return container - def get_source(self, source): sources = { - "Blu-ray" : "Blu-ray", - "BluRay" : "Blu-ray", - "HD DVD" : "HD-DVD", - "HDDVD" : "HD-DVD", - "Web" : "WEB", - "HDTV" : "HDTV", - 'UHDTV' : 'HDTV', - "NTSC" : "DVD", - "PAL" : "DVD" + "Blu-ray": "Blu-ray", + "BluRay": "Blu-ray", + "HD DVD": "HD-DVD", + "HDDVD": "HD-DVD", + "Web": "WEB", + "HDTV": "HDTV", + 'UHDTV': 'HDTV', + "NTSC": "DVD", + "PAL": "DVD" } source_id = sources.get(source, "OtherR") return source_id - def get_subtitles(self, meta): sub_lang_map = self.sub_lang_map @@ -448,7 +526,8 @@ def get_subtitles(self, meta): if language == "en": if track.get('Forced', "") == "Yes": language = "en (Forced)" - if "intertitles" in track.get('Title', "").lower(): + title = track.get('Title', "") + if isinstance(title, str) and "intertitles" in title.lower(): language = "en (Intertitles)" for lang, subID in sub_lang_map.items(): if language in lang and subID not in sub_langs: @@ -458,41 +537,49 @@ def get_subtitles(self, meta): for lang, subID in sub_lang_map.items(): if language in lang and subID not in sub_langs: sub_langs.append(subID) - + if sub_langs == []: - sub_langs = [44] # No Subtitle + sub_langs = [44] # No Subtitle return sub_langs def get_trumpable(self, sub_langs): trumpable_values = { - "English Hardcoded Subs (Full)" : 4, - "English Hardcoded Subs (Forced)" : 50, - "No English Subs" : 14, - "English Softsubs Exist (Mislabeled)" : None, - "Hardcoded Subs (Non-English)" : "OTHER" + "English Hardcoded Subs (Full)": 4, + "English Hardcoded Subs (Forced)": 50, + "No English Subs": 14, + "English Softsubs Exist (Mislabeled)": None, + "Hardcoded Subs (Non-English)": "OTHER" } - opts = cli_ui.select_choices("English subtitles not found. Please select any/all applicable options:", choices=list(trumpable_values.keys())) + opts = cli_ui.select_choices("Please select any/all applicable options:", choices=list(trumpable_values.keys())) trumpable = [] - if opts: - for t, v in trumpable_values.items(): - if t in ''.join(opts): - if v == None: - break - elif v != 50: # Hardcoded, Forced - trumpable.append(v) - elif v == "OTHER": #Hardcoded, Non-English - trumpable.append(14) - hc_sub_langs = cli_ui.ask_string("Enter language code for HC Subtitle languages") - for lang, subID in self.sub_lang_map.items(): - if any(hc_sub_langs.strip() == x for x in list(lang)): - sub_langs.append(subID) - else: - sub_langs.append(v) - trumpable.append(4) - + for opt in opts: + v = trumpable_values.get(opt) + if v is None: + continue + elif v == 4: + trumpable.append(4) + if 3 not in sub_langs: + sub_langs.append(3) + if 44 in sub_langs: + sub_langs.remove(44) + elif v == 50: + trumpable.append(50) + if 50 not in sub_langs: + sub_langs.append(50) + if 44 in sub_langs: + sub_langs.remove(44) + elif v == 14: + trumpable.append(14) + elif v == "OTHER": + trumpable.append(15) + hc_sub_langs = cli_ui.ask_string("Enter language code for HC Subtitle languages") + for lang, subID in self.sub_lang_map.items(): + if any(hc_sub_langs.strip() == x for x in list(lang)): + if subID not in sub_langs: + sub_langs.append(subID) sub_langs = list(set(sub_langs)) trumpable = list(set(trumpable)) - if trumpable == []: + if not trumpable: trumpable = None return trumpable, sub_langs @@ -506,7 +593,7 @@ def get_remaster_title(self, meta): remaster_title.append('The Criterion Collection') elif meta.get('distributor') in ('MASTERS OF CINEMA', 'MOC'): remaster_title.append('Masters of Cinema') - + # Editions # Director's Cut, Extended Edition, Rifftrax, Theatrical Cut, Uncut, Unrated if "director's cut" in meta.get('edition', '').lower(): @@ -527,7 +614,7 @@ def get_remaster_title(self, meta): # Features # 2-Disc Set, 2in1, 2D/3D Edition, 3D Anaglyph, 3D Full SBS, 3D Half OU, 3D Half SBS, - # 4K Restoration, 4K Remaster, + # 4K Restoration, 4K Remaster, # Extras, Remux, if meta.get('type') == "REMUX": remaster_title.append("Remux") @@ -541,25 +628,26 @@ def get_remaster_title(self, meta): remaster_title.append('Dual Audio') if "Dubbed" in meta['audio']: remaster_title.append('English Dub') - if meta.get('has_commentary', False) == True: - remaster_title.append('With Commentary') - - # HDR10, HDR10+, Dolby Vision, 10-bit, + # HDR10, HDR10+, Dolby Vision, 10-bit, # if "Hi10P" in meta.get('video_encode', ''): # remaster_title.append('10-bit') if meta.get('hdr', '').strip() == '' and meta.get('bit_depth') == '10': remaster_title.append('10-bit') + if "DV" in meta.get('hdr', ''): + remaster_title.append('Dolby Vision') if "HDR" in meta.get('hdr', ''): if "HDR10+" in meta['hdr']: remaster_title.append('HDR10+') else: remaster_title.append('HDR10') - if "DV" in meta.get('hdr', ''): - remaster_title.append('Dolby Vision') if "HLG" in meta.get('hdr', ''): remaster_title.append('HLG') + # with commentary always last + if meta.get('has_commentary', False) is True: + remaster_title.append('With Commentary') + if remaster_title != []: output = " / ".join(remaster_title) else: @@ -571,101 +659,494 @@ def convert_bbcode(self, desc): desc = desc.replace("[center]", "[align=center]").replace("[/center]", "[/align]") desc = desc.replace("[left]", "[align=left]").replace("[/left]", "[/align]") desc = desc.replace("[right]", "[align=right]").replace("[/right]", "[/align]") - desc = desc.replace("[code]", "[quote]").replace("[/code]", "[/quote]") + desc = desc.replace("[sup]", "").replace("[/sup]", "") + desc = desc.replace("[sub]", "").replace("[/sub]", "") + desc = desc.replace("[alert]", "").replace("[/alert]", "") + desc = desc.replace("[note]", "").replace("[/note]", "") + desc = desc.replace("[h1]", "[u][b]").replace("[/h1]", "[/b][/u]") + desc = desc.replace("[h2]", "[u][b]").replace("[/h2]", "[/b][/u]") + desc = desc.replace("[h3]", "[u][b]").replace("[/h3]", "[/b][/u]") + desc = desc.replace("[list]", "").replace("[/list]", "") + desc = desc.replace("[ul]", "").replace("[/ul]", "") + desc = desc.replace("[ol]", "").replace("[/ol]", "") + desc = desc.replace('[code', '[quote') + desc = desc.replace('[/code]', '[/quote]') + desc = re.sub(r"\[img=[^\]]+\]", "[img]", desc) return desc async def edit_desc(self, meta): - from src.prep import Prep - prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=self.config) base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding="utf-8").read() + multi_screens = int(self.config['DEFAULT'].get('multiScreens', 2)) + + # Check for saved pack_image_links.json file + pack_images_file = os.path.join(meta['base_dir'], "tmp", meta['uuid'], "pack_image_links.json") + pack_images_data = {} + if os.path.exists(pack_images_file): + try: + with open(pack_images_file, 'r', encoding='utf-8') as f: + pack_images_data = json.load(f) + if meta['debug']: + console.print(f"[green]Loaded previously uploaded images from {pack_images_file}") + console.print(f"[blue]Found {pack_images_data.get('total_count', 0)} previously uploaded images") + except Exception as e: + console.print(f"[yellow]Warning: Could not load pack image data: {str(e)}[/yellow]") + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding="utf-8") as desc: images = meta['image_list'] discs = meta.get('discs', []) - # For Discs - if len(discs) >= 1: - for i in range(len(discs)): - each = discs[i] + filelist = meta.get('filelist', []) + + # Handle single disc case + if len(discs) == 1: + each = discs[0] + new_screens = [] + bdinfo_keys = [] + if each['type'] == "BDMV": + bdinfo_keys = [key for key in each if key.startswith("bdinfo")] + bdinfo = meta.get('bdinfo') + if len(bdinfo_keys) > 1: + edition = bdinfo.get("edition", "Unknown Edition") + desc.write(f"[b]{edition}[/b]\n\n") + desc.write(f"[mediainfo]{each['summary']}[/mediainfo]\n\n") + base2ptp = self.convert_bbcode(base) + if base2ptp.strip() != "": + desc.write(base2ptp) + desc.write("\n\n") + try: + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + tonemapped_header = self.config['DEFAULT'].get('tonemapped_header') + tonemapped_header = self.convert_bbcode(tonemapped_header) + desc.write(tonemapped_header) + desc.write("\n\n") + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + for img_index in range(len(images[:int(meta['screens'])])): + raw_url = meta['image_list'][img_index]['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") + elif each['type'] == "DVD": + desc.write(f"[b][size=3]{each['name']}:[/size][/b]\n") + desc.write(f"[mediainfo]{each['ifo_mi_full']}[/mediainfo]\n") + desc.write(f"[mediainfo]{each['vob_mi_full']}[/mediainfo]\n\n") + base2ptp = self.convert_bbcode(base) + if base2ptp.strip() != "": + desc.write(base2ptp) + desc.write("\n\n") + for img_index in range(len(images[:int(meta['screens'])])): + raw_url = meta['image_list'][img_index]['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") + if len(bdinfo_keys) > 1: + if 'retry_count' not in meta: + meta['retry_count'] = 0 + + for i, key in enumerate(bdinfo_keys[1:], start=1): # Skip the first bdinfo + new_images_key = f'new_images_playlist_{i}' + bdinfo = each[key] + edition = bdinfo.get("edition", "Unknown Edition") + + # Find the corresponding summary for this bdinfo + summary_key = f"summary_{i}" if i > 0 else "summary" + summary = each.get(summary_key, "No summary available") + + # Check for saved images first + if pack_images_data and 'keys' in pack_images_data and new_images_key in pack_images_data['keys']: + saved_images = pack_images_data['keys'][new_images_key]['images'] + if saved_images: + if meta['debug']: + console.print(f"[yellow]Using saved images from pack_image_links.json for {new_images_key}") + + meta[new_images_key] = [] + for img in saved_images: + meta[new_images_key].append({ + 'img_url': img.get('img_url', ''), + 'raw_url': img.get('raw_url', ''), + 'web_url': img.get('web_url', '') + }) + + if new_images_key in meta and meta[new_images_key]: + desc.write(f"\n[b]{edition}[/b]\n\n") + # Use the summary corresponding to the current bdinfo + desc.write(f"[mediainfo]{summary}[/mediainfo]\n\n") + if meta['debug']: + console.print("[yellow]Using original uploaded images for first disc") + for img in meta[new_images_key]: + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + else: + desc.write(f"\n[b]{edition}[/b]\n") + # Use the summary corresponding to the current bdinfo + desc.write(f"[mediainfo]{summary}[/mediainfo]\n\n") + meta['retry_count'] += 1 + meta[new_images_key] = [] + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"PLAYLIST_{i}-*.png") + if not new_screens: + use_vs = meta.get('vapoursynth', False) + try: + await disc_screenshots(meta, f"PLAYLIST_{i}", bdinfo, meta['uuid'], meta['base_dir'], use_vs, [], meta.get('ffdebug', False), multi_screens, True) + except Exception as e: + print(f"Error during BDMV screenshot capture: {e}") + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"PLAYLIST_{i}-*.png") + if new_screens and not meta.get('skip_imghost_upload', False): + uploaded_images, _ = await upload_screens(meta, multi_screens, 1, 0, multi_screens, new_screens, {new_images_key: meta[new_images_key]}) + if uploaded_images and not meta.get('skip_imghost_upload', False): + await self.save_image_links(meta, new_images_key, uploaded_images) + for img in uploaded_images: + meta[new_images_key].append({ + 'img_url': img['img_url'], + 'raw_url': img['raw_url'], + 'web_url': img['web_url'] + }) + + for img in uploaded_images: + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + + meta_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json" + with open(meta_filename, 'w') as f: + json.dump(meta, f, indent=4) + + # Handle multiple discs case + elif len(discs) > 1: + if 'retry_count' not in meta: + meta['retry_count'] = 0 + if multi_screens < 2: + multi_screens = 2 + console.print("[yellow]PTP requires at least 2 screenshots for multi disc content, overriding config") + for i, each in enumerate(discs): + new_images_key = f'new_images_disc_{i}' if each['type'] == "BDMV": - desc.write(f"[mediainfo]{each['summary']}[/mediainfo]\n\n") if i == 0: + desc.write(f"[mediainfo]{each['summary']}[/mediainfo]\n\n") base2ptp = self.convert_bbcode(base) if base2ptp.strip() != "": desc.write(base2ptp) desc.write("\n\n") - mi_dump = each['summary'] + try: + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + tonemapped_header = self.config['DEFAULT'].get('tonemapped_header') + tonemapped_header = self.convert_bbcode(tonemapped_header) + desc.write(tonemapped_header) + desc.write("\n\n") + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + for img_index in range(min(multi_screens, len(meta['image_list']))): + raw_url = meta['image_list'][img_index]['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") else: - mi_dump = each['summary'] - if meta.get('vapoursynth', False) == True: - use_vs = True + desc.write(f"[mediainfo]{each['summary']}[/mediainfo]\n\n") + base2ptp = self.convert_bbcode(base) + if base2ptp.strip() != "": + desc.write(base2ptp) + desc.write("\n\n") + # Check for saved images first + if pack_images_data and 'keys' in pack_images_data and new_images_key in pack_images_data['keys']: + saved_images = pack_images_data['keys'][new_images_key]['images'] + if saved_images: + if meta['debug']: + console.print(f"[yellow]Using saved images from pack_image_links.json for {new_images_key}") + + meta[new_images_key] = [] + for img in saved_images: + meta[new_images_key].append({ + 'img_url': img.get('img_url', ''), + 'raw_url': img.get('raw_url', ''), + 'web_url': img.get('web_url', '') + }) + if new_images_key in meta and meta[new_images_key]: + for img in meta[new_images_key]: + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") else: - use_vs = False - ds = multiprocessing.Process(target=prep.disc_screenshots, args=(f"FILE_{i}", each['bdinfo'], meta['uuid'], meta['base_dir'], use_vs, [], meta.get('ffdebug', False), 2)) - ds.start() - while ds.is_alive() == True: - await asyncio.sleep(1) - new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}",f"FILE_{i}-*.png") - images, dummy = prep.upload_screens(meta, 2, 1, 0, 2, new_screens, {}) - - if each['type'] == "DVD": - desc.write(f"[b][size=3]{each['name']}:[/size][/b]\n") - desc.write(f"[mediainfo]{each['ifo_mi_full']}[/mediainfo]\n") - desc.write(f"[mediainfo]{each['vob_mi_full']}[/mediainfo]\n") - desc.write("\n") + meta['retry_count'] += 1 + meta[new_images_key] = [] + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + if not new_screens: + try: + await disc_screenshots(meta, f"FILE_{i}", each['bdinfo'], meta['uuid'], meta['base_dir'], meta.get('vapoursynth', False), [], meta.get('ffdebug', False), multi_screens, True) + except Exception as e: + print(f"Error during BDMV screenshot capture: {e}") + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + if new_screens and not meta.get('skip_imghost_upload', False): + uploaded_images, _ = await upload_screens(meta, multi_screens, 1, 0, multi_screens, new_screens, {new_images_key: meta[new_images_key]}) + if uploaded_images and not meta.get('skip_imghost_upload', False): + await self.save_image_links(meta, new_images_key, uploaded_images) + for img in uploaded_images: + meta[new_images_key].append({ + 'img_url': img['img_url'], + 'raw_url': img['raw_url'], + 'web_url': img['web_url'] + }) + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") + + meta_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json" + with open(meta_filename, 'w') as f: + json.dump(meta, f, indent=4) + + elif each['type'] == "DVD": if i == 0: + desc.write(f"[b][size=3]{each['name']}:[/size][/b]\n") + desc.write(f"[mediainfo]{each['ifo_mi_full']}[/mediainfo]\n") + desc.write(f"[mediainfo]{each['vob_mi_full']}[/mediainfo]\n\n") base2ptp = self.convert_bbcode(base) if base2ptp.strip() != "": desc.write(base2ptp) desc.write("\n\n") + for img_index in range(min(multi_screens, len(meta['image_list']))): + raw_url = meta['image_list'][img_index]['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") else: - ds = multiprocessing.Process(target=prep.dvd_screenshots, args=(meta, i, 2)) - ds.start() - while ds.is_alive() == True: - await asyncio.sleep(1) - new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"{meta['discs'][i]['name']}-*.png") - images, dummy = prep.upload_screens(meta, 2, 1, 0, 2, new_screens, {}) - - if len(images) > 0: - for each in range(len(images[:int(meta['screens'])])): - raw_url = images[each]['raw_url'] - desc.write(f"[img]{raw_url}[/img]\n") - desc.write("\n") - # For non-discs - elif len(meta.get('filelist', [])) >= 1: - for i in range(len(meta['filelist'])): - file = meta['filelist'][i] + desc.write(f"[b][size=3]{each['name']}:[/size][/b]\n") + desc.write(f"[mediainfo]{each['ifo_mi_full']}[/mediainfo]\n") + desc.write(f"[mediainfo]{each['vob_mi_full']}[/mediainfo]\n\n") + base2ptp = self.convert_bbcode(base) + if base2ptp.strip() != "": + desc.write(base2ptp) + desc.write("\n\n") + # Check for saved images first + if pack_images_data and 'keys' in pack_images_data and new_images_key in pack_images_data['keys']: + saved_images = pack_images_data['keys'][new_images_key]['images'] + if saved_images: + if meta['debug']: + console.print(f"[yellow]Using saved images from pack_image_links.json for {new_images_key}") + + meta[new_images_key] = [] + for img in saved_images: + meta[new_images_key].append({ + 'img_url': img.get('img_url', ''), + 'raw_url': img.get('raw_url', ''), + 'web_url': img.get('web_url', '') + }) + if new_images_key in meta and meta[new_images_key]: + for img in meta[new_images_key]: + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") + else: + meta['retry_count'] += 1 + meta[new_images_key] = [] + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"{meta['discs'][i]['name']}-*.png") + if not new_screens: + try: + await dvd_screenshots( + meta, i, multi_screens, True + ) + except Exception as e: + print(f"Error during DVD screenshot capture: {e}") + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"{meta['discs'][i]['name']}-*.png") + if new_screens and not meta.get('skip_imghost_upload', False): + uploaded_images, _ = await upload_screens(meta, multi_screens, 1, 0, multi_screens, new_screens, {new_images_key: meta[new_images_key]}) + if uploaded_images and not meta.get('skip_imghost_upload', False): + await self.save_image_links(meta, new_images_key, uploaded_images) + for img in uploaded_images: + meta[new_images_key].append({ + 'img_url': img['img_url'], + 'raw_url': img['raw_url'], + 'web_url': img['web_url'] + }) + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") + + meta_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json" + with open(meta_filename, 'w') as f: + json.dump(meta, f, indent=4) + + # Handle single file case + elif len(filelist) == 1: + file = filelist[0] + if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '' and meta.get('description', None) is None and self.web_source is True: + desc.write(f"[quote][align=center]This release is sourced from {meta['service_longname']}[/align][/quote]") + mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() + desc.write(f"[mediainfo]{mi_dump}[/mediainfo]\n") + base2ptp = self.convert_bbcode(base) + if base2ptp.strip() != "": + desc.write(base2ptp) + desc.write("\n\n") + if meta.get('comparison', None): + if 'comparison_groups' in meta and meta['comparison_groups']: + desc.write("\n") + + comparison_groups = meta['comparison_groups'] + group_keys = sorted(comparison_groups.keys(), key=lambda x: int(x)) + comparison_names = [comparison_groups[key].get('name', f'Group {key}') for key in group_keys] + comparison_header = ', '.join(comparison_names) + desc.write(f"[comparison={comparison_header}]\n") + + num_images = min([len(comparison_groups[key]['urls']) for key in group_keys]) + + for img_index in range(num_images): + for key in group_keys: + group = comparison_groups[key] + if img_index < len(group['urls']): + img_data = group['urls'][img_index] + raw_url = img_data.get('raw_url', '') + if raw_url: + desc.write(f"[img]{raw_url}[/img] ") + desc.write("\n") + + desc.write("[/comparison]\n\n") + + try: + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + tonemapped_header = self.config['DEFAULT'].get('tonemapped_header') + tonemapped_header = self.convert_bbcode(tonemapped_header) + desc.write(tonemapped_header) + desc.write("\n\n") + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + + for img_index in range(len(images[:int(meta['screens'])])): + raw_url = meta['image_list'][img_index]['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") + + # Handle multiple files case + elif len(filelist) > 1: + if multi_screens < 2: + multi_screens = 2 + console.print("[yellow]PTP requires at least 2 screenshots for multi disc/file content, overriding config") + for i in range(len(filelist)): + file = filelist[i] if i == 0: - # Add This line for all web-dls - if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '' and meta.get('description', None) == None and self.web_source == True: + if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '' and meta.get('description', None) is None and self.web_source is True: desc.write(f"[quote][align=center]This release is sourced from {meta['service_longname']}[/align][/quote]") - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - else: - # Export Mediainfo - mi_dump = MediaInfo.parse(file, output="STRING", full=False, mediainfo_options={'inform_version' : '1'}) - # mi_dump = mi_dump.replace(file, os.path.basename(file)) - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/TEMP_PTP_MEDIAINFO.txt", "w", newline="", encoding="utf-8") as f: - f.write(mi_dump) - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/TEMP_PTP_MEDIAINFO.txt", "r", encoding="utf-8").read() - # Generate and upload screens for other files - s = multiprocessing.Process(target=prep.screenshots, args=(file, f"FILE_{i}", meta['uuid'], meta['base_dir'], meta, 2)) - s.start() - while s.is_alive() == True: - await asyncio.sleep(3) - new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}",f"FILE_{i}-*.png") - images, dummy = prep.upload_screens(meta, 2, 1, 0, 2, new_screens, {}) - - desc.write(f"[mediainfo]{mi_dump}[/mediainfo]\n") - if i == 0: base2ptp = self.convert_bbcode(base) if base2ptp.strip() != "": desc.write(base2ptp) - desc.write("\n\n") - if len(images) > 0: - for each in range(len(images[:int(meta['screens'])])): - raw_url = images[each]['raw_url'] + desc.write("\n\n") + mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() + desc.write(f"[mediainfo]{mi_dump}[/mediainfo]\n") + try: + if meta.get('tonemapped', False) and self.config['DEFAULT'].get('tonemapped_header', None): + tonemapped_header = self.config['DEFAULT'].get('tonemapped_header') + tonemapped_header = self.convert_bbcode(tonemapped_header) + desc.write(tonemapped_header) + desc.write("\n\n") + except Exception as e: + console.print(f"[yellow]Warning: Error setting tonemapped header: {str(e)}[/yellow]") + for img_index in range(min(multi_screens, len(meta['image_list']))): + raw_url = meta['image_list'][img_index]['raw_url'] desc.write(f"[img]{raw_url}[/img]\n") - desc.write("\n") + desc.write("\n") + else: + mi_dump = MediaInfo.parse(file, output="STRING", full=False) + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/TEMP_PTP_MEDIAINFO.txt", "w", newline="", encoding="utf-8") as f: + f.write(mi_dump.replace(file, os.path.basename(file))) + mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/TEMP_PTP_MEDIAINFO.txt", "r", encoding="utf-8").read() + desc.write(f"[mediainfo]{mi_dump}[/mediainfo]\n") + new_images_key = f'new_images_file_{i}' + # Check for saved images first + if pack_images_data and 'keys' in pack_images_data and new_images_key in pack_images_data['keys']: + saved_images = pack_images_data['keys'][new_images_key]['images'] + if saved_images: + if meta['debug']: + console.print(f"[yellow]Using saved images from pack_image_links.json for {new_images_key}") + + meta[new_images_key] = [] + for img in saved_images: + meta[new_images_key].append({ + 'img_url': img.get('img_url', ''), + 'raw_url': img.get('raw_url', ''), + 'web_url': img.get('web_url', '') + }) + if new_images_key in meta and meta[new_images_key]: + for img in meta[new_images_key]: + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") + else: + meta['retry_count'] = meta.get('retry_count', 0) + 1 + meta[new_images_key] = [] + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + if not new_screens: + try: + await screenshots( + file, f"FILE_{i}", meta['uuid'], meta['base_dir'], meta, multi_screens, True, None) + except Exception as e: + print(f"Error during generic screenshot capture: {e}") + new_screens = glob.glob1(f"{meta['base_dir']}/tmp/{meta['uuid']}", f"FILE_{i}-*.png") + if new_screens and not meta.get('skip_imghost_upload', False): + uploaded_images, _ = await upload_screens(meta, multi_screens, 1, 0, multi_screens, new_screens, {new_images_key: meta[new_images_key]}) + if uploaded_images and not meta.get('skip_imghost_upload', False): + await self.save_image_links(meta, new_images_key, uploaded_images) + for img in uploaded_images: + meta[new_images_key].append({ + 'img_url': img['img_url'], + 'raw_url': img['raw_url'], + 'web_url': img['web_url'] + }) + raw_url = img['raw_url'] + desc.write(f"[img]{raw_url}[/img]\n") + desc.write("\n") - + meta_filename = f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json" + with open(meta_filename, 'w') as f: + json.dump(meta, f, indent=4) + + async def save_image_links(self, meta, image_key, image_list=None): + if image_list is None: + console.print("[yellow]No image links to save.[/yellow]") + return None + + output_dir = os.path.join(meta['base_dir'], "tmp", meta['uuid']) + os.makedirs(output_dir, exist_ok=True) + output_file = os.path.join(output_dir, "pack_image_links.json") + + # Load existing data if the file exists + existing_data = {} + if os.path.exists(output_file): + try: + with open(output_file, 'r', encoding='utf-8') as f: + existing_data = json.load(f) + except Exception as e: + console.print(f"[yellow]Warning: Could not load existing image data: {str(e)}[/yellow]") + + # Create data structure if it doesn't exist yet + if not existing_data: + existing_data = { + "keys": {}, + "total_count": 0 + } + + # Update the data with the new images under the specific key + if image_key not in existing_data["keys"]: + existing_data["keys"][image_key] = { + "count": 0, + "images": [] + } + + # Add new images to the specific key + for idx, img in enumerate(image_list): + image_entry = { + "index": existing_data["keys"][image_key]["count"] + idx, + "raw_url": img.get("raw_url", ""), + "web_url": img.get("web_url", ""), + "img_url": img.get("img_url", ""), + } + existing_data["keys"][image_key]["images"].append(image_entry) + + # Update counts + existing_data["keys"][image_key]["count"] = len(existing_data["keys"][image_key]["images"]) + existing_data["total_count"] = sum(key_data["count"] for key_data in existing_data["keys"].values()) + + try: + with open(output_file, 'w', encoding='utf-8') as f: + json.dump(existing_data, f, indent=2) + + if meta['debug']: + console.print(f"[green]Saved {len(image_list)} new images for key '{image_key}' (total: {existing_data['total_count']}):[/green]") + console.print(f"[blue] - JSON: {output_file}[/blue]") + + return output_file + except Exception as e: + console.print(f"[bold red]Error saving image links: {e}[/bold red]") + return None async def get_AntiCsrfToken(self, meta): if not os.path.exists(f"{meta['base_dir']}/data/cookies"): @@ -680,17 +1161,17 @@ async def get_AntiCsrfToken(self, meta): loggedIn = await self.validate_login(uploadresponse) else: console.print("[yellow]PTP Cookies not found. Creating new session.") - if loggedIn == True: + if loggedIn is True: AntiCsrfToken = re.search(r'data-AntiCsrfToken="(.*)"', uploadresponse.text).group(1) else: - passKey = re.match(r"https?://please\.passthepopcorn\.me:?\d*/(.+)/announce",self.announce_url).group(1) + passKey = re.match(r"https?://please\.passthepopcorn\.me:?\d*/(.+)/announce", self.announce_url).group(1) data = { "username": self.username, "password": self.password, "passkey": passKey, "keeplogged": "1", } - headers = {"User-Agent" : self.user_agent} + headers = {"User-Agent": self.user_agent} loginresponse = session.post("https://passthepopcorn.me/ajax.php?action=login", data=data, headers=headers) await asyncio.sleep(2) try: @@ -703,14 +1184,14 @@ async def get_AntiCsrfToken(self, meta): resp = loginresponse.json() try: if resp["Result"] != "Ok": - raise LoginException("Failed to login to PTP. Probably due to the bad user name, password, announce url, or 2FA code.") + raise LoginException("Failed to login to PTP. Probably due to the bad user name, password, announce url, or 2FA code.") # noqa F405 AntiCsrfToken = resp["AntiCsrfToken"] with open(cookiefile, 'wb') as cf: pickle.dump(session.cookies, cf) except Exception: - raise LoginException(f"Got exception while loading JSON login response from PTP. Response: {loginresponse.text}") + raise LoginException(f"Got exception while loading JSON login response from PTP. Response: {loginresponse.text}") # noqa F405 except Exception: - raise LoginException(f"Got exception while loading JSON login response from PTP. Response: {loginresponse.text}") + raise LoginException(f"Got exception while loading JSON login response from PTP. Response: {loginresponse.text}") # noqa F405 return AntiCsrfToken async def validate_login(self, response): @@ -718,7 +1199,7 @@ async def validate_login(self, response): if response.text.find("""""") != -1: console.print("Looks like you are not logged in to PTP. Probably due to the bad user name, password, or expired session.") elif "Your popcorn quota has been reached, come back later!" in response.text: - raise LoginException("Your PTP request/popcorn quota has been reached, try again later") + raise LoginException("Your PTP request/popcorn quota has been reached, try again later") # noqa F405 else: loggedIn = True return loggedIn @@ -728,55 +1209,124 @@ async def fill_upload_form(self, groupID, meta): await common.edit_torrent(meta, self.tracker, self.source_flag) resolution, other_resolution = self.get_resolution(meta) await self.edit_desc(meta) - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", "r").read() + file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + try: + os.stat(file_path) # Ensures the file is accessible + with open(file_path, "r", encoding="utf-8") as f: + desc = f.read() + except OSError as e: + print(f"File error: {e}") ptp_subtitles = self.get_subtitles(meta) + no_audio_found = False + english_audio = False + if meta['is_disc'] == 'BDMV': + bdinfo = meta.get('bdinfo', {}) + audio_tracks = bdinfo.get("audio", []) + if audio_tracks: + first_language = str(audio_tracks[0].get("Language", "")).lower() + if not first_language: + no_audio_found = True + elif first_language.startswith("en"): + english_audio = True + else: + english_audio = False + else: + mediainfo = meta.get('mediainfo', {}) + audio_tracks = [track for track in mediainfo.get("media", {}).get("track", []) if track.get("@type") == "Audio"] + if meta['debug']: + console.print(f"[Debug] Found {len(audio_tracks)} audio tracks") + + if not audio_tracks: + no_audio_found = True + console.print("[yellow]No audio tracks found in mediainfo") + else: + first_language = str(audio_tracks[0].get("Language", "")).lower() + if meta['debug']: + console.print(f"[Debug] First audio track language: {first_language}") + + if not first_language: + no_audio_found = True + elif first_language.startswith("en"): + english_audio = True + else: + english_audio = False + ptp_trumpable = None - if not any(x in [3, 50] for x in ptp_subtitles) or meta['hardcoded-subs']: + if meta['hardcoded-subs']: ptp_trumpable, ptp_subtitles = self.get_trumpable(ptp_subtitles) + if ptp_trumpable and 50 in ptp_trumpable: + ptp_trumpable.remove(50) + ptp_trumpable.append(4) + if ptp_trumpable and 14 in ptp_trumpable: + if 44 in ptp_subtitles: + ptp_subtitles.remove(44) + if ptp_trumpable and 15 in ptp_trumpable: + ptp_trumpable.remove(15) + ptp_trumpable.append(4) + if 44 in ptp_subtitles: + ptp_subtitles.remove(44) + if not english_audio and (not any(x in [3, 50] for x in ptp_subtitles)): + ptp_trumpable.append(14) + + elif no_audio_found and (not any(x in [3, 50] for x in ptp_subtitles)): + cli_ui.info("No English subs and no audio tracks found should this be trumpable?") + if cli_ui.ask_yes_no("Mark trumpable?", default=True): + ptp_trumpable, ptp_subtitles = self.get_trumpable(ptp_subtitles) + elif not english_audio and (not any(x in [3, 50] for x in ptp_subtitles)): + cli_ui.info("No English subs and English audio is not the first audio track, should this be trumpable?") + if cli_ui.ask_yes_no("Mark trumpable?", default=True): + ptp_trumpable, ptp_subtitles = self.get_trumpable(ptp_subtitles) + + if meta['debug']: + console.print("ptp_trumpable", ptp_trumpable) + console.print("ptp_subtitles", ptp_subtitles) data = { "submit": "true", "remaster_year": "", - "remaster_title": self.get_remaster_title(meta), #Eg.: Hardcoded English + "remaster_title": self.get_remaster_title(meta), # Eg.: Hardcoded English "type": self.get_type(meta['imdb_info'], meta), - "codec": "Other", # Sending the codec as custom. + "codec": "Other", # Sending the codec as custom. "other_codec": self.get_codec(meta), "container": "Other", "other_container": self.get_container(meta), "resolution": resolution, - "source": "Other", # Sending the source as custom. + "source": "Other", # Sending the source as custom. "other_source": self.get_source(meta['source']), "release_desc": desc, "nfo_text": "", - "subtitles[]" : ptp_subtitles, - "trumpable[]" : ptp_trumpable, - "AntiCsrfToken" : await self.get_AntiCsrfToken(meta) - } + "subtitles[]": ptp_subtitles, + "trumpable[]": ptp_trumpable, + "AntiCsrfToken": await self.get_AntiCsrfToken(meta) + } if data["remaster_year"] != "" or data["remaster_title"] != "": data["remaster"] = "on" if resolution == "Other": data["other_resolution"] = other_resolution - if meta.get('personalrelease', False) == True: + if meta.get('personalrelease', False) is True: data["internalrip"] = "on" # IF SPECIAL (idk how to check for this automatically) # data["special"] = "on" - if int(str(meta.get("imdb_id", "0")).replace('tt', '')) == 0: + if int(meta.get("imdb_id")) == 0: data["imdb"] = "0" else: - data["imdb"] = meta["imdb_id"] - + data["imdb"] = str(meta["imdb_id"]).zfill(7) - if groupID == None: # If need to make new group + if groupID is None: # If need to make new group url = "https://passthepopcorn.me/upload.php" - if data["imdb"] == "0": + if data["imdb"] == '0': tinfo = await self.get_torrent_info_tmdb(meta) else: - tinfo = await self.get_torrent_info(meta.get("imdb_id", "0"), meta) + tinfo = await self.get_torrent_info(meta.get("imdb"), meta) + if meta.get('youtube', None) is None or "youtube" not in str(meta.get('youtube', '')): + youtube = "" if meta['unattended'] else cli_ui.ask_string("Unable to find youtube trailer, please link one e.g.(https://www.youtube.com/watch?v=dQw4w9WgXcQ)", default="") + meta['youtube'] = youtube cover = meta["imdb_info"].get("cover") - if cover == None: + if cover is None: cover = meta.get('poster') - if cover != None and "ptpimg" not in cover: + if cover is not None and "ptpimg" not in cover: cover = await self.ptpimg_url_rehost(cover) - while cover == None: + while cover is None: cover = cli_ui.ask_string("No Poster was found. Please input a link to a poster: \n", default="") if "ptpimg" not in str(cover) and str(cover).endswith(('.jpg', '.png')): cover = await self.ptpimg_url_rehost(cover) @@ -791,33 +1341,87 @@ async def fill_upload_form(self, groupID, meta): if new_data['year'] in ['', '0', 0, None] and meta.get('manual_year') not in [0, '', None]: new_data['year'] = meta['manual_year'] while new_data["tags"] == "": - if meta.get('mode', 'discord') == 'cli': + if meta.get('mode', 'discord') == 'cli': console.print('[yellow]Unable to match any tags') console.print("Valid tags can be found on the PTP upload form") new_data["tags"] = console.input("Please enter at least one tag. Comma seperated (action, animation, short):") data.update(new_data) - if meta["imdb_info"].get("directors", None) != None: + if meta["imdb_info"].get("directors", None) is not None: data["artist[]"] = tuple(meta['imdb_info'].get('directors')) data["importance[]"] = "1" - else: # Upload on existing group + else: # Upload on existing group url = f"https://passthepopcorn.me/upload.php?groupid={groupID}" data["groupid"] = groupID return url, data - async def upload(self, meta, url, data): - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') as torrentFile: + async def upload(self, meta, url, data, disctype): + torrent_filename = f"[{self.tracker}].torrent" + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/{torrent_filename}" + torrent = Torrent.read(torrent_path) + + # Check if the piece size exceeds 16 MiB and regenerate the torrent if needed + if torrent.piece_size > 16777216: # 16 MiB in bytes + console.print("[red]Piece size is OVER 16M and does not work on PTP. Generating a new .torrent") + if meta.get('mkbrr', False): + from data.config import config + common = COMMON(config=self.config) + tracker_url = config['TRACKERS']['PTP'].get('announce_url', "https://fake.tracker").strip() + + # Create the torrent with the tracker URL + torrent_create = f"[{self.tracker}]" + create_torrent(meta, meta['path'], torrent_create, tracker_url=tracker_url) + torrent_filename = "[PTP]" + + await common.edit_torrent(meta, self.tracker, self.source_flag, torrent_filename=torrent_filename) + else: + if meta['is_disc']: + include = [] + exclude = [] + else: + include = ["*.mkv", "*.mp4", "*.ts"] + exclude = ["*.*", "*sample.mkv", "!sample*.*"] + + new_torrent = CustomTorrent( + meta=meta, + path=Path(meta['path']), + trackers=[self.announce_url], + source="Audionut", + private=True, + exclude_globs=exclude, # Ensure this is always a list + include_globs=include, # Ensure this is always a list + creation_date=datetime.now(), + comment="Created by Upload Assistant", + created_by="Upload Assistant" + ) + + # Explicitly set the piece size and update metainfo + new_torrent.piece_size = 16777216 # 16 MiB in bytes + new_torrent.metainfo['info']['piece length'] = 16777216 # Ensure 'piece length' is set + + # Validate and write the new torrent + new_torrent.validate_piece_size() + new_torrent.generate(callback=torf_cb, interval=5) + new_torrent.write(torrent_path, overwrite=True) + + # Proceed with the upload process + with open(torrent_path, 'rb') as torrentFile: files = { - "file_input" : ("placeholder.torrent", torrentFile, "application/x-bittorent") + "file_input": ("placeholder.torrent", torrentFile, "application/x-bittorent") } headers = { # 'ApiUser' : self.api_user, # 'ApiKey' : self.api_key, - "User-Agent": self.user_agent + "User-Agent": self.user_agent } if meta['debug']: + debug_data = data.copy() + # Redact the AntiCsrfToken + if 'AntiCsrfToken' in debug_data: + debug_data['AntiCsrfToken'] = '[REDACTED]' console.log(url) - console.log(data) + console.log(redact_private_info(debug_data)) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." else: with requests.Session() as session: cookiefile = f"{meta['base_dir']}/data/cookies/PTP.pickle" @@ -826,23 +1430,25 @@ async def upload(self, meta, url, data): response = session.post(url=url, data=data, headers=headers, files=files) console.print(f"[cyan]{response.url}") responsetext = response.text - # If the repsonse contains our announce url then we are on the upload page and the upload wasn't successful. + # If the response contains our announce URL, then we are on the upload page and the upload wasn't successful. if responsetext.find(self.announce_url) != -1: # Get the error message. - #
No torrent file uploaded, or file is empty.
errorMessage = "" match = re.search(r"""
= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url=url, params=params) + if response.status_code == 200: + data = response.json() + for each in data['data']: + result = [each][0]['attributes']['name'] + dupes.append(result) + else: + console.print(f"[bold red]Failed to search torrents. HTTP Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out after 5 seconds") + except httpx.RequestError as e: + console.print(f"[bold red]Unable to search for existing torrents: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") await asyncio.sleep(5) - return dupes \ No newline at end of file + return dupes diff --git a/src/trackers/RAS.py b/src/trackers/RAS.py new file mode 100644 index 000000000..3424863a9 --- /dev/null +++ b/src/trackers/RAS.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +import aiofiles +from data.config import config +from src.console import console +from src.languages import process_desc_language +from src.tmdb import get_logo +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class RAS(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='RAS') + self.config = config + self.common = COMMON(config) + self.tracker = 'RAS' + self.source_flag = 'Rastastugan' + self.base_url = 'https://rastastugan.org' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = ['YTS', 'YiFY', 'LAMA', 'MeGUSTA', 'NAHOM', 'GalaxyRG', 'RARBG', 'INFINITY'] + pass + + async def get_additional_checks(self, meta): + should_continue = True + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + nordic_languages = ['Danish', 'Swedish', 'Norwegian', 'Icelandic', 'Finnish', 'English'] + if not any(lang in meta.get('audio_languages', []) for lang in nordic_languages) and not any(lang in meta.get('subtitle_languages', []) for lang in nordic_languages): + console.print(f'[bold red]{self.tracker} requires at least one Nordic/English audio or subtitle track.') + should_continue = False + + return should_continue + + async def get_description(self, meta): + if meta.get('logo', "") == "": + TMDB_API_KEY = config['DEFAULT'].get('tmdb_api', False) + TMDB_BASE_URL = "https://api.themoviedb.org/3" + tmdb_id = meta.get('tmdb') + category = meta.get('category') + debug = meta.get('debug') + logo_languages = ['da', 'sv', 'no', 'fi', 'is', 'en'] + logo_path = await get_logo(tmdb_id, category, debug, logo_languages=logo_languages, TMDB_API_KEY=TMDB_API_KEY, TMDB_BASE_URL=TMDB_BASE_URL) + if logo_path: + meta['logo'] = logo_path + await self.common.unit3d_edit_desc(meta, self.tracker, self.signature) + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8') as f: + desc = await f.read() + return {'description': desc} diff --git a/src/trackers/RF.py b/src/trackers/RF.py index ca94837b9..ca75f2b6a 100644 --- a/src/trackers/RF.py +++ b/src/trackers/RF.py @@ -1,137 +1,75 @@ # -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os -import platform - +import re from src.trackers.COMMON import COMMON from src.console import console +from src.trackers.UNIT3D import UNIT3D -class RF(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - ############################################################### - ######## EDIT ME ######## - ############################################################### +class RF(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='RF') self.config = config + self.common = COMMON(config) self.tracker = 'RF' self.source_flag = 'ReelFliX' - self.upload_url = 'https://reelflix.xyz/api/torrents/upload' - self.search_url = 'https://reelflix.xyz/api/torrents/filter' - self.forum_link = "\n[center][url=https://github.com/L4GSP1KE/Upload-Assistant]Created by Upload Assistant[/url][/center]" - self.banned_groups = [""] + self.base_url = 'https://reelflix.xyz' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] pass - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await common.unit3d_edit_desc(meta, self.tracker, self.forum_link) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - stt_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : stt_name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() - } + + async def get_additional_checks(self, meta): + should_continue = True + + disallowed_keywords = {'XXX', 'Erotic', 'softcore'} + if any(keyword.lower() in disallowed_keywords for keyword in map(str.lower, meta['keywords'])): + if not meta['unattended']: + console.print('[bold red]Erotic not allowed at RF.') + should_continue = False if meta.get('category') == "TV": - console.print('[bold red]This site only ALLOWS Movies.') - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() + if not meta['unattended']: + console.print('[bold red]RF only ALLOWS Movies.') + should_continue = False + return should_continue + async def get_name(self, meta): + rf_name = meta['name'] + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] - async def edit_name(self, meta): - stt_name = meta['name'] - return stt_name + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + rf_name = re.sub(f"-{invalid_tag}", "", rf_name, flags=re.IGNORECASE) + rf_name = f"{rf_name}-NoGroup" - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - }.get(category_name, '0') - return category_id + return {'name': rf_name} - async def get_type_id(self, type): + async def get_type_id(self, meta, type=None, reverse=False, mapping_only=False): type_id = { 'DISC': '43', 'REMUX': '40', 'WEBDL': '42', 'WEBRIP': '45', - #'FANRES': '6', + # 'FANRES': '6', 'ENCODE': '41', 'HDTV': '35', - }.get(type, '0') - return type_id + } + if mapping_only: + return type_id + elif reverse: + return {v: k for k, v in type_id.items()} + elif type is not None: + return {'type_id': type_id.get(type, '0')} + else: + meta_type = meta.get('type', '') + resolved_id = type_id.get(meta_type, '0') + return {'type_id': resolved_id} - async def get_res_id(self, resolution): + async def get_resolution_id(self, meta, resolution=None, reverse=False, mapping_only=False): resolution_id = { # '8640p':'10', '4320p': '1', @@ -139,41 +77,19 @@ async def get_res_id(self, resolution): # '1440p' : '3', '1080p': '3', '1080i': '4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" + '720p': '5', + '576p': '6', + '576i': '7', + '480p': '8', + '480i': '9' } - if meta['category'] == 'TV': - console.print('[bold red]Unable to search site for TV as this site only ALLOWS Movies') - # params['name'] = f"{meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + meta['edition'] - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes + if mapping_only: + return resolution_id + elif reverse: + return {v: k for k, v in resolution_id.items()} + elif resolution is not None: + return {'resolution_id': resolution_id.get(resolution, '10')} + else: + meta_resolution = meta.get('resolution', '') + resolved_id = resolution_id.get(meta_resolution, '10') + return {'resolution_id': resolved_id} diff --git a/src/trackers/RTF.py b/src/trackers/RTF.py index d67dc9bff..0c7dce6e8 100644 --- a/src/trackers/RTF.py +++ b/src/trackers/RTF.py @@ -1,14 +1,16 @@ # -*- coding: utf-8 -*- # import discord import asyncio -import requests import base64 import re import datetime +import httpx +import aiofiles from src.trackers.COMMON import COMMON from src.console import console + class RTF(): """ Edit for Tracker: @@ -18,54 +20,54 @@ class RTF(): Upload """ - ############################################################### - ######## EDIT ME ######## - ############################################################### def __init__(self, config): self.config = config self.tracker = 'RTF' self.source_flag = 'sunshine' self.upload_url = 'https://retroflix.club/api/upload' self.search_url = 'https://retroflix.club/api/torrent' + self.torrent_url = 'https://retroflix.club/browse/t/' self.forum_link = 'https://retroflix.club/forums.php?action=viewtopic&topicid=3619' self.banned_groups = [] pass - async def upload(self, meta): + async def upload(self, meta, disctype): common = COMMON(config=self.config) await common.edit_torrent(meta, self.tracker, self.source_flag) await common.unit3d_edit_desc(meta, self.tracker, self.forum_link) - if meta['bdinfo'] != None: + if meta['bdinfo'] is not None: mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') as f: + bd_dump = await f.read() else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') as f: + mi_dump = await f.read() bd_dump = None screenshots = [] for image in meta['image_list']: - if image['raw_url'] != None: + if image['raw_url'] is not None: screenshots.append(image['raw_url']) json_data = { - 'name' : meta['name'], + 'name': meta['name'], # description does not work for some reason # 'description' : meta['overview'] + "\n\n" + desc + "\n\n" + "Uploaded by L4G Upload Assistant", 'description': "this is a description", # editing mediainfo so that instead of 1 080p its 1,080p as site mediainfo parser wont work other wise. - 'mediaInfo': re.sub("(\d+)\s+(\d+)", r"\1,\2", mi_dump) if bd_dump == None else f"{bd_dump}", + 'mediaInfo': re.sub(r"(\d+)\s+(\d+)", r"\1,\2", mi_dump) if bd_dump is None else f"{bd_dump}", "nfo": "", - "url": "https://www.imdb.com/title/" + (meta['imdb_id'] if str(meta['imdb_id']).startswith("tt") else "tt" + meta['imdb_id']) + "/", + "url": "https://www.imdb.com/title/" + (meta['imdb_id'] if str(meta['imdb_id']).startswith("tt") else "tt" + str(meta['imdb_id'])) + "/", # auto pulled from IMDB "descr": "This is short description", - "poster": meta["poster"] if meta["poster"] != None else "", + "poster": meta["poster"] if meta["poster"] is not None else "", "type": "401" if meta['category'] == 'MOVIE'else "402", "screenshots": screenshots, 'isAnonymous': self.config['TRACKERS'][self.tracker]["anon"], } - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') as binary_file: - binary_file_data = binary_file.read() + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent", 'rb') as binary_file: + binary_file_data = await binary_file.read() base64_encoded_data = base64.b64encode(binary_file_data) base64_message = base64_encoded_data.decode('utf-8') json_data['file'] = base64_message @@ -76,50 +78,160 @@ async def upload(self, meta): 'Authorization': self.config['TRACKERS'][self.tracker]['api_key'].strip(), } - - if datetime.date.today().year - meta['year'] <= 9: - console.print(f"[red]ERROR: Not uploading!\nMust be older than 10 Years as per rules") - return - - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, json=json_data, headers=headers) + if meta['debug'] is False: try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") + async with httpx.AsyncClient(timeout=15.0) as client: + response = await client.post(url=self.upload_url, json=json_data, headers=headers) + try: + response_json = response.json() + meta['tracker_status'][self.tracker]['status_message'] = response.json() + + t_id = response_json['torrent']['id'] + meta['tracker_status'][self.tracker]['torrent_id'] = t_id + await common.add_tracker_torrent(meta, self.tracker, self.source_flag, + self.config['TRACKERS'][self.tracker].get('announce_url'), + "https://retroflix.club/browse/t/" + str(t_id)) + + except Exception: + console.print("It may have uploaded, go check") + return + except httpx.TimeoutException: + meta['tracker_status'][self.tracker]['status_message'] = "data error: RTF request timed out while uploading." + except httpx.RequestError as e: + meta['tracker_status'][self.tracker]['status_message'] = f"data error: An error occurred while making the request: {e}" + except Exception: + meta['tracker_status'][self.tracker]['status_message'] = "data error - It may have uploaded, go check" return + else: - console.print(f"[cyan]Request Data:") + console.print("[cyan]RTF Request Data:") console.print(json_data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." + + async def search_existing(self, meta, disctype): + disallowed_keywords = {'XXX', 'Erotic', 'softcore'} + if any(keyword.lower() in disallowed_keywords for keyword in map(str.lower, meta['keywords'])): + console.print('[bold red]Erotic not allowed at RTF.') + meta['skipping'] = "RTF" + return [] + if meta.get('category') == "TV" and meta.get('tv_year') is not None: + if datetime.date.today().year - meta['tv_year'] <= 9: + console.print("[red]Content must be older than 10 Years to upload at RTF") + meta['skipping'] = "RTF" + return [] - async def search_existing(self, meta): dupes = [] - console.print("[yellow]Searching for existing torrents on site...") headers = { 'accept': 'application/json', 'Authorization': self.config['TRACKERS'][self.tracker]['api_key'].strip(), } + params = {'includingDead': '1'} - params = { - 'includingDead' : '1' - } - - # search is intentionally vague and just uses IMDB if available as many releases are not named properly on site. - if meta['imdb_id'] != "0": - params['imdbId'] = meta['imdb_id'] if str(meta['imdb_id']).startswith("tt") else "tt" + meta['imdb_id'] + if meta['imdb_id'] != 0: + params['imdbId'] = str(meta['imdb_id']) if str(meta['imdb_id']).startswith("tt") else "tt" + str(meta['imdb_id']) else: params['search'] = meta['title'].replace(':', '').replace("'", '').replace(",", '') try: - response = requests.get(url=self.search_url, params=params, headers=headers) - response = response.json() - for each in response: - result = [each][0]['name'] - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(self.search_url, params=params, headers=headers) + if response.status_code == 200: + data = response.json() + for each in data: + result = { + 'name': each['name'], + 'size': each['size'], + 'files': each['name'], + 'link': each['url'], + } + dupes.append(result) + else: + console.print(f"[bold red]HTTP request failed. Status: {response.status_code}") + + except httpx.TimeoutException: + console.print("[bold red]Request timed out while searching for existing torrents.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + console.print_exception() await asyncio.sleep(5) - return dupes \ No newline at end of file + return dupes + + # Tests if stored API key is valid. Site API key expires every week so a new one has to be generated. + async def api_test(self, meta): + headers = { + 'accept': 'application/json', + 'Authorization': self.config['TRACKERS'][self.tracker]['api_key'].strip(), + } + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get('https://retroflix.club/api/test', headers=headers) + + if response.status_code != 200: + console.print('[bold red]Your API key is incorrect SO generating a new one') + await self.generate_new_api(meta) + else: + return + except httpx.RequestError as e: + console.print(f'[bold red]Error testing API: {str(e)}') + await self.generate_new_api(meta) + except Exception as e: + console.print(f'[bold red]Unexpected error testing API: {str(e)}') + await self.generate_new_api(meta) + + async def generate_new_api(self, meta): + headers = { + 'accept': 'application/json', + } + + json_data = { + 'username': self.config['TRACKERS'][self.tracker]['username'], + 'password': self.config['TRACKERS'][self.tracker]['password'], + } + + base_dir = meta.get('base_dir', '.') + config_path = f"{base_dir}/data/config.py" + + try: + async with httpx.AsyncClient() as client: + response = await client.post('https://retroflix.club/api/login', headers=headers, json=json_data) + + if response.status_code == 201: + token = response.json().get("token") + if token: + console.print('[bold green]Saving and using New API key generated for this upload') + console.print(f'[bold yellow]{token[:10]}...[/bold yellow]') + + # Update the in-memory config dictionary + self.config['TRACKERS'][self.tracker]['api_key'] = token + + # Now we update the config file on disk using utf-8 encoding + with open(config_path, 'r', encoding='utf-8') as file: + config_data = file.read() + + # Find the RTF tracker and replace the api_key value + new_config_data = re.sub( + r'("RTF":\s*{[^}]*"api_key":\s*)([\'"])[^\'"]*([\'"])([^\}]*})', + rf'\1\2{token}\3\4', + config_data + ) + + # Write the updated config back to the file + with open(config_path, 'w', encoding='utf-8') as file: + file.write(new_config_data) + + console.print(f'[bold green]API Key successfully saved to {config_path}') + else: + console.print('[bold red]API response does not contain a token.') + else: + console.print(f'[bold red]Error getting new API key: {response.status_code}, please check username and password in the config.') + + except httpx.RequestError as e: + console.print(f'[bold red]An error occurred while requesting the API: {str(e)}') + + except Exception as e: + console.print(f'[bold red]An unexpected error occurred: {str(e)}') diff --git a/src/trackers/SAM.py b/src/trackers/SAM.py new file mode 100644 index 000000000..0794c8e08 --- /dev/null +++ b/src/trackers/SAM.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class SAM(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='SAM') + self.config = config + self.common = COMMON(config) + self.tracker = 'SAM' + self.source_flag = 'SAMARITANO' + self.base_url = 'https://samaritano.cc' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass diff --git a/src/trackers/SHRI.py b/src/trackers/SHRI.py new file mode 100644 index 000000000..fa049a2c9 --- /dev/null +++ b/src/trackers/SHRI.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# import discord +import re +from src.languages import process_desc_language +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class SHRI(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='SHRI') + self.config = config + self.common = COMMON(config) + self.tracker = 'SHRI' + self.source_flag = 'ShareIsland' + self.base_url = 'https://shareisland.org' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_name(self, meta): + shareisland_name = meta['name'] + resolution = meta.get('resolution') + video_codec = meta.get('video_codec') + video_encode = meta.get('video_encode') + name_type = meta.get('type', "") + source = meta.get('source', "") + imdb_info = meta.get('imdb_info') or {} + + akas = imdb_info.get('akas', []) + italian_title = None + + remove_list = ['Dubbed'] + for each in remove_list: + shareisland_name = shareisland_name.replace(each, '') + + for aka in akas: + if isinstance(aka, dict) and aka.get("country") == "Italy": + italian_title = aka.get("title") + break + + use_italian_title = self.config['TRACKERS'][self.tracker].get('use_italian_title', False) + if italian_title and use_italian_title: + shareisland_name = shareisland_name.replace(meta.get('aka', ''), '') + shareisland_name = shareisland_name.replace(meta.get('title', ''), italian_title) + + audio_lang_str = "" + + tag_lower = meta['tag'].lower() + invalid_tags = ["nogrp", "nogroup", "unknown", "-unk-"] + + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + if meta.get('audio_languages'): + audio_languages = [] + for lang in meta['audio_languages']: + lang_up = lang.upper() + if lang_up not in audio_languages: + audio_languages.append(lang_up) + audio_lang_str = " - ".join(audio_languages) + + audios = [] + if 'mediainfo' in meta and 'media' in meta['mediainfo'] and 'track' in meta['mediainfo']['media']: + audios = [ + audio for audio in meta['mediainfo']['media']['track'][2:] + if audio.get('@type') == 'Audio' + and isinstance(audio.get('Language'), str) + and audio.get('Language').lower() in {'it', 'it-it'} + and "commentary" not in str(audio.get('Title', '')).lower() + ] + + subs = [] + if 'mediainfo' in meta and 'media' in meta['mediainfo'] and 'track' in meta['mediainfo']['media']: + subs = [ + sub for sub in meta['mediainfo']['media']['track'] + if sub.get('@type') == 'Text' + and isinstance(sub.get('Language'), str) + and sub['Language'].lower() in {'it', 'it-it'} + ] + + if len(audios) > 0: + shareisland_name = shareisland_name + elif len(subs) > 0: + if not meta.get('tag'): + shareisland_name = shareisland_name + " [SUBS]" + else: + shareisland_name = shareisland_name.replace(meta['tag'], f" [SUBS]{meta['tag']}") + + if meta.get('dual_audio'): + shareisland_name = shareisland_name.replace("Dual-Audio", "", 1) + + if audio_lang_str: + if name_type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD"): + shareisland_name = shareisland_name.replace(str(meta['year']), f"{meta['year']} {audio_lang_str}", 1) + elif not meta.get('is_disc') == "BDMV": + shareisland_name = shareisland_name.replace(meta['resolution'], f"{audio_lang_str} {meta['resolution']}", 1) + + if name_type == "DVDRIP": + source = "DVDRip" + shareisland_name = shareisland_name.replace(f"{meta['source']} ", "", 1) + shareisland_name = shareisland_name.replace(f"{meta['video_encode']}", "", 1) + shareisland_name = shareisland_name.replace(f"{source}", f"{resolution} {source}", 1) + shareisland_name = shareisland_name.replace((meta['audio']), f"{meta['audio']}{video_encode}", 1) + + elif meta['is_disc'] == "DVD" or (name_type == "REMUX" and source in ("PAL DVD", "NTSC DVD", "DVD")): + shareisland_name = shareisland_name.replace((meta['source']), f"{resolution} {meta['source']}", 1) + shareisland_name = shareisland_name.replace((meta['audio']), f"{video_codec} {meta['audio']}", 1) + + if meta['tag'] == "" or any(invalid_tag in tag_lower for invalid_tag in invalid_tags): + for invalid_tag in invalid_tags: + shareisland_name = re.sub(f"-{invalid_tag}", "", shareisland_name, flags=re.IGNORECASE) + shareisland_name = f"{shareisland_name}-NoGroup" + + shareisland_name = re.sub(r'\s{2,}', ' ', shareisland_name) + + return {'name': shareisland_name} + + async def get_type_id(self, meta): + type_id = { + 'DISC': '26', + 'REMUX': '7', + 'WEBDL': '27', + 'WEBRIP': '15', + 'HDTV': '6', + 'ENCODE': '15', + }.get(meta['type'], '0') + return {'type_id': type_id} diff --git a/src/trackers/SN.py b/src/trackers/SN.py index 54f13d64d..d01af25c4 100644 --- a/src/trackers/SN.py +++ b/src/trackers/SN.py @@ -1,21 +1,13 @@ # -*- coding: utf-8 -*- import requests import asyncio -import traceback +import httpx from src.trackers.COMMON import COMMON from src.console import console class SN(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - def __init__(self, config): self.config = config self.tracker = 'SN' @@ -31,20 +23,38 @@ async def get_type_id(self, type): 'BluRay': '3', 'Web': '1', # boxset is 4 - #'NA': '4', + # 'NA': '4', 'DVD': '2' }.get(type, '0') return type_id - async def upload(self, meta): + async def upload(self, meta, disctype): common = COMMON(config=self.config) await common.edit_torrent(meta, self.tracker, self.source_flag) - #await common.unit3d_edit_desc(meta, self.tracker, self.forum_link) + # await common.unit3d_edit_desc(meta, self.tracker, self.forum_link) await self.edit_desc(meta) cat_id = "" sub_cat_id = "" - #cat_id = await self.get_cat_id(meta) - if meta['category'] == 'MOVIE': + # cat_id = await self.get_cat_id(meta) + + # Anime + if meta.get('mal_id'): + cat_id = 7 + sub_cat_id = 47 + + demographics_map = { + 'Shounen': 27, + 'Seinen': 28, + 'Shoujo': 29, + 'Josei': 30, + 'Kodomo': 31, + 'Mina': 47 + } + + demographic = meta.get('demographic', 'Mina') + sub_cat_id = demographics_map.get(demographic, sub_cat_id) + + elif meta['category'] == 'MOVIE': cat_id = 1 # sub cat is source so using source to get sub_cat_id = await self.get_type_id(meta['source']) @@ -56,16 +66,15 @@ async def upload(self, meta): sub_cat_id = 5 # todo need to do a check for docs and add as subcat - - if meta['bdinfo'] != None: + if meta['bdinfo'] is not None: mi_dump = None bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() else: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() + desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') as f: + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent", 'rb') as f: tfile = f.read() f.close() @@ -84,34 +93,38 @@ async def upload(self, meta): 'name': meta['name'], 'category_id': cat_id, 'type_id': sub_cat_id, - 'media_ref': f"tt{meta['imdb_id']}", + 'media_ref': f"tt{meta['imdb']}", 'description': desc, 'media_info': mi_dump } - if meta['debug'] == False: + if meta['debug'] is False: response = requests.request("POST", url=self.upload_url, data=data, files=files) try: if response.json().get('success'): - console.print(response.json()) + meta['tracker_status'][self.tracker]['status_message'] = response.json()['link'] + if 'link' in response.json(): + await common.add_tracker_torrent(meta, self.tracker, self.source_flag, self.config['TRACKERS'][self.tracker].get('announce_url'), str(response.json()['link'])) + else: + console.print("[red]No Link in Response") else: console.print("[red]Did not upload successfully") console.print(response.json()) - except: + except Exception: console.print("[red]Error! It may have uploaded, go check") console.print(data) console.print_exception() return else: - console.print(f"[cyan]Request Data:") + console.print("[cyan]Request Data:") console.print(data) - + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as desc: + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8').read() + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf-8') as desc: desc.write(base) images = meta['image_list'] if len(images) > 0: @@ -125,38 +138,44 @@ async def edit_desc(self, meta): desc.close() return - - async def search_existing(self, meta): + async def search_existing(self, meta, disctype): dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_key' : self.config['TRACKERS'][self.tracker]['api_key'].strip() + 'api_key': self.config['TRACKERS'][self.tracker]['api_key'].strip() } - # using title if IMDB id does not exist to search + # Determine search parameters based on metadata if meta['imdb_id'] == 0: if meta['category'] == 'TV': - params['filter'] = meta['title'] + f"{meta.get('season', '')}{meta.get('episode', '')}" + " " + meta['resolution'] + params['filter'] = f"{meta['title']}{meta.get('season', '')}" else: params['filter'] = meta['title'] else: - #using IMDB_id to search if it exists. + params['media_ref'] = f"tt{meta['imdb']}" if meta['category'] == 'TV': - params['media_ref'] = f"tt{meta['imdb_id']}" - params['filter'] = f"{meta.get('season', '')}{meta.get('episode', '')}" + " " + meta['resolution'] + params['filter'] = f"{meta.get('season', '')}" else: - params['media_ref'] = f"tt{meta['imdb_id']}" params['filter'] = meta['resolution'] try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for i in response['data']: - result = i['name'] - dupes.append(result) - except: - console.print('[red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(self.search_url, params=params) + if response.status_code == 200: + data = response.json() + for i in data.get('data', []): + result = i.get('name') + if result: + dupes.append(result) + else: + console.print(f"[bold red]HTTP request failed. Status: {response.status_code}") + + except httpx.TimeoutException: + console.print("[bold red]Request timed out while searching for existing torrents.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + console.print_exception() await asyncio.sleep(5) return dupes diff --git a/src/trackers/SP.py b/src/trackers/SP.py new file mode 100644 index 000000000..e9830da36 --- /dev/null +++ b/src/trackers/SP.py @@ -0,0 +1,93 @@ +# -*- coding: utf-8 -*- +import re +import os +from src.trackers.COMMON import COMMON +from src.console import console +from src.trackers.UNIT3D import UNIT3D + + +class SP(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='SP') + self.config = config + self.common = COMMON(config) + self.tracker = 'SP' + self.source_flag = 'seedpool.org' + self.base_url = 'https://seedpool.org' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_category_id(self, meta): + if not isinstance(meta, dict): + raise TypeError('meta must be a dict when passed to Seedpool get_cat_id') + + category_name = meta.get('category', '').upper() + release_title = meta.get('name', '') + mal_id = meta.get('mal_id', 0) + + # Custom SEEDPOOL category logic + # Anime TV go in the Anime category + if mal_id != 0 and category_name == 'TV': + return {'category_id': '6'} + + # Sports + if self.contains_sports_patterns(release_title): + return {'category_id': '8'} + + # Default category logic + category_id = { + 'MOVIE': '1', + 'TV': '2', + }.get(category_name, '0') + return {'category_id': category_id} + + # New function to check for sports releases in a title + def contains_sports_patterns(self, release_title): + patterns = [ + r'EFL.*', r'.*mlb.*', r'.*formula1.*', r'.*nascar.*', r'.*nfl.*', r'.*wrc.*', r'.*wwe.*', + r'.*fifa.*', r'.*boxing.*', r'.*rally.*', r'.*ufc.*', r'.*ppv.*', r'.*uefa.*', r'.*nhl.*', + r'.*nba.*', r'.*motogp.*', r'.*moto2.*', r'.*moto3.*', r'.*gamenight.*', r'.*darksport.*', + r'.*overtake.*' + ] + + for pattern in patterns: + if re.search(pattern, release_title, re.IGNORECASE): + return True + return False + + async def get_type_id(self, meta): + type_id = { + 'DISC': '1', + 'REMUX': '2', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6', + 'ENCODE': '3', + 'DVDRIP': '3' + }.get(meta['type'], '0') + return {'type_id': type_id} + + async def get_name(self, meta): + KNOWN_EXTENSIONS = {".mkv", ".mp4", ".avi", ".ts"} + if meta['scene'] is True: + if meta.get('scene_name') != "": + name = meta.get('scene_name') + else: + name = meta['uuid'].replace(" ", ".") + elif meta.get('is_disc') is True: + name = meta['name'].replace(" ", ".") + else: + if meta.get('mal_id', 0) != 0: + name = meta['name'].replace(" ", ".") + else: + name = meta['uuid'].replace(" ", ".") + base, ext = os.path.splitext(name) + if ext.lower() in KNOWN_EXTENSIONS: + name = base.replace(" ", ".") + console.print(f"[cyan]Name: {name}") + + return {'name': name} diff --git a/src/trackers/SPD.py b/src/trackers/SPD.py new file mode 100644 index 000000000..52d047220 --- /dev/null +++ b/src/trackers/SPD.py @@ -0,0 +1,319 @@ +# -*- coding: utf-8 -*- +# import discord +import base64 +import bencodepy +import glob +import hashlib +import httpx +import os +import re +import unicodedata +from src.languages import process_desc_language +from src.console import console +from .COMMON import COMMON + + +class SPD: + def __init__(self, config): + self.url = "https://speedapp.io" + self.config = config + self.common = COMMON(config) + self.tracker = 'SPD' + self.passkey = self.config['TRACKERS'][self.tracker]['passkey'] + self.upload_url = 'https://speedapp.io/api/upload' + self.torrent_url = 'https://speedapp.io/browse/' + self.announce_list = [ + f"http://ramjet.speedapp.io/{self.passkey}/announce", + f"http://ramjet.speedapp.to/{self.passkey}/announce", + f"http://ramjet.speedappio.org/{self.passkey}/announce", + f"https://ramjet.speedapp.io/{self.passkey}/announce", + f"https://ramjet.speedapp.to/{self.passkey}/announce", + f"https://ramjet.speedappio.org/{self.passkey}/announce" + ] + self.banned_groups = [] + self.ua_name = f'Upload Assistant {self.common.get_version()}'.strip() + self.signature = f'[center][url=https://github.com/Audionut/Upload-Assistant]Created by {self.ua_name}[/url][/center]' + self.session = httpx.AsyncClient(headers={ + 'User-Agent': "Upload Assistant", + 'accept': 'application/json', + 'Authorization': self.config['TRACKERS'][self.tracker]['api_key'], + }, timeout=30.0) + + async def get_cat_id(self, meta): + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + + langs = [lang.lower() for lang in meta.get('subtitle_languages', []) + meta.get('audio_languages', [])] + romanian = 'romanian' in langs + + if 'RO' in meta.get('origin_country', []): + if meta.get('category') == 'TV': + return '60' + elif meta.get('category') == 'MOVIE': + return '59' + + # documentary + if 'documentary' in meta.get("genres", "").lower() or 'documentary' in meta.get("keywords", "").lower(): + return '63' if romanian else '9' + + # anime + if meta.get('anime'): + return '3' + + # TV + if meta.get('category') == 'TV': + if meta.get('tv_pack'): + return '66' if romanian else '41' + elif meta.get('sd'): + return '46' if romanian else '45' + return '44' if romanian else '43' + + # MOVIE + if meta.get('category') == 'MOVIE': + if meta.get('resolution') == '2160p' and meta.get('type') != 'DISC': + return '57' if romanian else '61' + if meta.get('type') in ('REMUX', 'WEBDL', 'WEBRIP', 'HDTV', 'ENCODE'): + return '29' if romanian else '8' + if meta.get('type') == 'DISC': + return '24' if romanian else '17' + if meta.get('type') == 'SD': + return '35' if romanian else '10' + + return None + + async def get_file_info(self, meta): + base_path = f"{meta['base_dir']}/tmp/{meta['uuid']}" + + if meta.get('bdinfo'): + bd_info = open(f"{base_path}/BD_SUMMARY_00.txt", encoding='utf-8').read() + return None, bd_info + else: + media_info = open(f"{base_path}/MEDIAINFO_CLEANPATH.txt", encoding='utf-8').read() + return media_info, None + + async def get_screenshots(self, meta): + screenshots = [] + if len(meta['image_list']) != 0: + for image in meta['image_list']: + screenshots.append(image['raw_url']) + + return screenshots + + async def search_existing(self, meta, disctype): + results = [] + search_url = 'https://speedapp.io/api/torrent' + + params = {} + if meta.get('imdb_id', 0) != 0: + params['imdbId'] = f"{meta.get('imdb_info', {}).get('imdbID', '')}" + else: + search_title = meta['title'].replace(':', '').replace("'", '').replace(',', '') + params['search'] = search_title + + try: + response = await self.session.get(url=search_url, params=params, headers=self.session.headers) + + if response.status_code == 200: + data = response.json() + for each in data: + name = each.get('name') + size = each.get('size') + link = f'{self.torrent_url}{each.get("id")}/' + + if name: + results.append({ + 'name': name, + 'size': size, + 'link': link + }) + return results + else: + console.print(f'[bold red]HTTP request failed. Status: {response.status_code}') + + except Exception as e: + console.print(f'[bold red]Unexpected error: {e}') + console.print_exception() + + return results + + async def search_channel(self, meta): + spd_channel = meta.get('spd_channel', '') or self.config['TRACKERS'][self.tracker].get('channel', '') + + # if no channel is specified, use the default + if not spd_channel: + return 1 + + # return the channel as int if it's already an integer + if isinstance(spd_channel, int): + return spd_channel + + # if user enters id as a string number + if isinstance(spd_channel, str): + if spd_channel.isdigit(): + return int(spd_channel) + # if user enter tag then it will use API to search + else: + pass + + params = { + 'search': spd_channel + } + + try: + response = await self.session.get(url=self.url + '/api/channel', params=params, headers=self.session.headers) + + if response.status_code == 200: + data = response.json() + for entry in data: + id = entry['id'] + tag = entry['tag'] + + if id and tag: + if tag != spd_channel: + console.print(f'[{self.tracker}]: Unable to find a matching channel based on your input. Please check if you entered it correctly.') + return + else: + return id + else: + console.print(f'[{self.tracker}]: Could not find the channel ID. Please check if you entered it correctly.') + + else: + console.print(f"[bold red]HTTP request failed. Status: {response.status_code}") + + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + console.print_exception() + + async def edit_desc(self, meta): + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + description_parts = [] + + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as f: + manual_desc = f.read() + description_parts.append(manual_desc) + + custom_description_header = self.config['DEFAULT'].get('custom_description_header', '') + if custom_description_header: + description_parts.append(custom_description_header) + + if self.signature: + description_parts.append(self.signature) + + final_description = "\n\n".join(filter(None, description_parts)) + desc = final_description + desc = re.sub(r"\[center\]\[spoiler=.*? NFO:\]\[code\](.*?)\[/code\]\[/spoiler\]\[/center\]", r"", desc, flags=re.DOTALL) + desc = re.sub(r"(\[spoiler=[^]]+])", "[spoiler]", desc, flags=re.IGNORECASE) + desc = re.sub(r'\[img(?:[^\]]*)\]', '[img]', desc, flags=re.IGNORECASE) + desc = re.sub(r'\n{3,}', '\n\n', desc) + + with open(final_desc_path, 'w', encoding='utf-8') as f: + f.write(desc) + + return desc + + async def edit_name(self, meta): + torrent_name = meta['name'] + + name = torrent_name.replace(':', '-') + name = unicodedata.normalize("NFKD", name) + name = name.encode("ascii", "ignore").decode("ascii") + name = re.sub(r'[\\/*?"<>|]', '', name) + + return name + + async def get_source_flag(self, meta): + torrent = f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent" + + with open(torrent, "rb") as f: + torrent_data = bencodepy.decode(f.read()) + info = bencodepy.encode(torrent_data[b'info']) + source_flag = hashlib.sha1(info).hexdigest() + self.source_flag = f"speedapp.io-{source_flag}-" + await self.common.edit_torrent(meta, self.tracker, self.source_flag) + + return + + async def encode_to_base64(self, file_path): + with open(file_path, 'rb') as binary_file: + binary_file_data = binary_file.read() + base64_encoded_data = base64.b64encode(binary_file_data) + return base64_encoded_data.decode('utf-8') + + async def get_nfo(self, meta): + nfo_dir = os.path.join(meta['base_dir'], "tmp", meta['uuid']) + nfo_files = glob.glob(os.path.join(nfo_dir, "*.nfo")) + + if nfo_files: + nfo = await self.encode_to_base64(nfo_files[0]) + return nfo + + return None + + async def fetch_data(self, meta): + await self.get_source_flag(meta) + media_info, bd_info = await self.get_file_info(meta) + + data = { + 'bdInfo': bd_info, + 'coverPhotoUrl': meta.get('backdrop', ''), + 'description': meta.get('genres', ''), + 'media_info': media_info, + 'name': await self.edit_name(meta), + 'nfo': await self.get_nfo(meta), + 'plot': meta.get('overview_meta', '') or meta.get('overview', ''), + 'poster': meta.get('poster', ''), + 'technicalDetails': await self.edit_desc(meta), + 'screenshots': await self.get_screenshots(meta), + 'type': await self.get_cat_id(meta), + 'url': f"https://www.imdb.com/title/{meta.get('imdb_info', {}).get('imdbID', '')}", + } + + if not meta.get('debug', False): + data['file'] = await self.encode_to_base64(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent") + + return data + + async def upload(self, meta, disctype): + data = await self.fetch_data(meta) + + channel = await self.search_channel(meta) + if channel is None: + meta['skipping'] = f"{self.tracker}" + return + channel = str(channel) + data['channel'] = channel + + status_message = '' + torrent_id = '' + + if meta['debug'] is False: + response = await self.session.post(url=self.upload_url, json=data, headers=self.session.headers) + + if response.status_code == 201: + + response = response.json() + status_message = response + + if 'downloadUrl' in response: + torrent_id = str(response.get('torrent', {}).get('id', '')) + if torrent_id: + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + else: + console.print("[bold red]No downloadUrl in response.") + console.print("[bold red]Confirm it uploaded correctly and try to download manually") + console.print(response) + + else: + console.print(f"[bold red]Failed to upload got status code: {response.status_code}") + + else: + console.print(data) + status_message = "Debug mode enabled, not uploading." + + await self.common.add_tracker_torrent(meta, self.tracker, self.source_flag + channel, self.announce_list, self.torrent_url + torrent_id) + + meta['tracker_status'][self.tracker]['status_message'] = status_message diff --git a/src/trackers/STC.py b/src/trackers/STC.py index 224e89889..947ee5d94 100644 --- a/src/trackers/STC.py +++ b/src/trackers/STC.py @@ -1,195 +1,48 @@ # -*- coding: utf-8 -*- -import asyncio -import requests -from difflib import SequenceMatcher -import distutils.util -import json -import os -import platform - from src.trackers.COMMON import COMMON -from src.console import console +from src.trackers.UNIT3D import UNIT3D + -class STC(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ +class STC(UNIT3D): def __init__(self, config): + super().__init__(config, tracker_name='STC') self.config = config + self.common = COMMON(config) self.tracker = 'STC' self.source_flag = 'STC' - self.upload_url = 'https://skipthecommericals.xyz/api/torrents/upload' - self.search_url = 'https://skipthecommericals.xyz/api/torrents/filter' - self.signature = '\n[center][url=https://skipthecommericals.xyz/pages/1]Please Seed[/url][/center]' + self.base_url = 'https://skipthecommericals.xyz' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' self.banned_groups = [""] pass - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type'], meta.get('tv_pack', 0), meta.get('sd', 0), meta.get('category', "")) - resolution_id = await self.get_res_id(meta['resolution']) - stc_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : stc_name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - open_torrent.close() - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - async def edit_name(self, meta): - stc_name = meta.get('name') - return stc_name - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id - - async def get_type_id(self, type, tv_pack, sd, category): + async def get_type_id(self, meta): type_id = { - 'DISC': '1', + 'DISC': '1', 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', + 'WEBDL': '4', + 'WEBRIP': '5', 'HDTV': '6', 'ENCODE': '3' - }.get(type, '0') - if tv_pack == 1: - if sd == 1: + }.get(meta.get('type'), '0') + if meta.get('tv_pack'): + if meta.get('sd'): # Season SD type_id = '14' - if type == "ENCODE": + if meta.get('type') == "ENCODE": type_id = '18' - if sd == 0: + if meta.get('sd'): # Season HD type_id = '13' - if type == "ENCODE": + if meta.get('type') == "ENCODE": type_id = '18' - if type == "DISC" and category == "TV": - if sd == 1: + if meta.get('type') == "DISC" and meta.get('category') == "TV": + if meta.get('sd') == 1: # SD-RETAIL type_id = '17' - if sd == 0: + if meta.get('sd') == 0: # HD-RETAIL type_id = '18' - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - - - - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type'], meta.get('tv_pack', 0), meta.get('sd', 0), meta.get('category', "")), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = f"{meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] + meta['edition'] - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes \ No newline at end of file + return {'type_id': type_id} diff --git a/src/trackers/STT.py b/src/trackers/STT.py deleted file mode 100644 index 0a72f7eab..000000000 --- a/src/trackers/STT.py +++ /dev/null @@ -1,170 +0,0 @@ -# -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -from difflib import SequenceMatcher -import distutils.util -import json -import os -import platform - -from src.trackers.COMMON import COMMON -from src.console import console - -class STT(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - def __init__(self, config): - self.config = config - self.tracker = 'STT' - self.source_flag = 'STT' - self.search_url = 'https://skipthetrailers.xyz/api/torrents/filter' - self.upload_url = 'https://skipthetrailers.xyz/api/torrents/upload' - self.signature = '\n[center][url=https://skipthetrailers.xyz/pages/1]Please Seed[/url][/center]' - self.banned_groups = [""] - pass - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - stt_name = await self.edit_name(meta) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : stt_name, - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' - } - params = { - 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if meta.get('category') == "TV": - console.print('[bold red]This site only ALLOWS Movies.') - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - async def edit_name(self, meta): - stt_name = meta['name'] - return stt_name - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - }.get(category_name, '0') - return category_id - - async def get_type_id(self, type): - type_id = { - 'DISC': '1', - 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', - 'FANRES': '6', - 'ENCODE': '3' - }.get(type, '0') - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - # '8640p':'10', - '4320p': '1', - '2160p': '2', - # '1440p' : '3', - '1080p': '3', - '1080i': '4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '11') - return resolution_id - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - console.print('[bold red]Unable to search site for TV as this site only ALLOWS Movies.') - # params['name'] = f"{meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + meta['edition'] - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes \ No newline at end of file diff --git a/src/trackers/TDC.py b/src/trackers/TDC.py deleted file mode 100644 index e201bcb83..000000000 --- a/src/trackers/TDC.py +++ /dev/null @@ -1,181 +0,0 @@ -# -*- coding: utf-8 -*- -# import discord -import asyncio -import requests -import distutils.util -import os - -from src.trackers.COMMON import COMMON -from src.console import console - -class TDC(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - - ############################################################### - ######## EDIT ME ######## - ############################################################### - def __init__(self, config): - self.config = config - self.tracker = 'TDC' - self.source_flag = 'TDC' - self.upload_url = 'https://thedarkcommunity.cc/api/torrents/upload' - self.search_url = 'https://thedarkcommunity.cc/api/torrents/filter' - self.signature = "Created by L4G's Upload Assistant" - self.banned_groups = [""] - pass - - async def get_cat_id(self, category_name): - category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id - - async def get_type_id(self, type): - type_id = { - 'DISC': '1', - 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', - 'HDTV': '6', - 'ENCODE': '3' - }.get(type, '0') - return type_id - - async def get_res_id(self, resolution): - resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', - '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', - '576i': '7', - '480p': '8', - '480i': '9' - }.get(resolution, '10') - return resolution_id - - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} - data = { - 'name' : meta['name'], - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:53.0) Gecko/20100101 Firefox/53.0' - } - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta['category'] == 'TV': - params['name'] = params['name'] + f"{meta.get('season', '')}{meta.get('episode', '')}" - if meta.get('edition', "") != "": - params['name'] = params['name'] + meta['edition'] - - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) - - return dupes diff --git a/src/trackers/THR.py b/src/trackers/THR.py index 3080ae581..02bef2de3 100644 --- a/src/trackers/THR.py +++ b/src/trackers/THR.py @@ -1,47 +1,42 @@ # -*- coding: utf-8 -*- import asyncio -from torf import Torrent import requests import json import glob -from difflib import SequenceMatcher import cli_ui -import base64 import os import re import platform +import httpx +from bs4 import BeautifulSoup from unidecode import unidecode - -from src.console import console +from src.console import console +from src.trackers.COMMON import COMMON class THR(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ def __init__(self, config): self.config = config + self.tracker = 'THR' + self.source_flag = '[https://www.torrenthr.org] TorrentHR.org' self.username = config['TRACKERS']['THR'].get('username') self.password = config['TRACKERS']['THR'].get('password') self.banned_groups = [""] pass - - async def upload(self, session, meta): - await self.edit_torrent(meta) + + async def upload(self, meta, disctype): + common = COMMON(config=self.config) + await common.edit_torrent(meta, self.tracker, self.source_flag) cat_id = await self.get_cat_id(meta) subs = self.get_subtitles(meta) - pronfo = await self.edit_desc(meta) + pronfo = await self.edit_desc(meta) # noqa #F841 thr_name = unidecode(meta['name'].replace('DD+', 'DDP')) - # Confirm the correct naming order for FL + # Confirm the correct naming order for THR cli_ui.info(f"THR name: {thr_name}") - if meta.get('unattended', False) == False: + if meta.get('unattended', False) is False: thr_confirm = cli_ui.ask_yes_no("Correct?", default=False) - if thr_confirm != True: + if thr_confirm is not True: thr_name_manually = cli_ui.ask_string("Please enter a proper name", default="") if thr_name_manually == "": console.print('No proper name given') @@ -49,8 +44,7 @@ async def upload(self, session, meta): return else: thr_name = thr_name_manually - torrent_name = re.sub("[^0-9a-zA-Z. '\-\[\]]+", " ", thr_name) - + torrent_name = re.sub(r"[^0-9a-zA-Z. '\-\[\]]+", " ", thr_name) if meta.get('is_disc', '') == 'BDMV': mi_file = None @@ -62,61 +56,91 @@ async def upload(self, session, meta): f.close() # bd_file = None - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[THR]DESCRIPTION.txt", 'r') as f: + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[THR]DESCRIPTION.txt", 'r', encoding='utf-8') as f: desc = f.read() f.close() - - torrent_path = os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/[THR]{meta['clean_name']}.torrent") + + torrent_path = os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/[THR].torrent") with open(torrent_path, 'rb') as f: tfile = f.read() f.close() - - #Upload Form + + # Upload Form url = 'https://www.torrenthr.org/takeupload.php' files = { - 'tfile' : (f'{torrent_name}.torrent', tfile) + 'tfile': (f'{torrent_name}.torrent', tfile) } payload = { - 'name' : thr_name, - 'descr' : desc, - 'type' : cat_id, - 'url' : f"https://www.imdb.com/title/tt{meta.get('imdb_id').replace('tt', '')}/", - 'tube' : meta.get('youtube', '') + 'name': thr_name, + 'descr': desc, + 'type': cat_id, + 'url': f"https://www.imdb.com/title/tt{meta.get('imdb')}/", + 'tube': meta.get('youtube', '') } headers = { - 'User-Agent' : f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' + 'User-Agent': f'Upload Assistant/2.3 ({platform.system()} {platform.release()})' } - #If pronfo fails, put mediainfo into THR parser + # If pronfo fails, put mediainfo into THR parser if meta.get('is_disc', '') != 'BDMV': files['nfo'] = ("MEDIAINFO.txt", mi_file) if subs != []: payload['subs[]'] = tuple(subs) - - if meta['debug'] == False: + if meta['debug'] is False: thr_upload_prompt = True else: thr_upload_prompt = cli_ui.ask_yes_no("send to takeupload.php?", default=False) - if thr_upload_prompt == True: + + if thr_upload_prompt is True: await asyncio.sleep(0.5) - response = session.post(url=url, files=files, data=payload, headers=headers) try: + cookies = await self.login() + + if cookies: + console.print("[green]Using authenticated session for upload") + + async with httpx.AsyncClient(cookies=cookies, follow_redirects=True) as session: + response = await session.post(url=url, files=files, data=payload, headers=headers) + + if meta['debug']: + console.print(f"[dim]Response status: {response.status_code}") + console.print(f"[dim]Response URL: {response.url}") + console.print(response.text[:500] + "...") + + if "uploaded=1" in str(response.url): + meta['tracker_status'][self.tracker]['status_message'] = response.url + return True + else: + console.print(f"[yellow]Upload response didn't contain 'uploaded=1'. URL: {response.url}") + soup = BeautifulSoup(response.text, 'html.parser') + error_text = soup.find('h2', string=lambda text: text and 'Error' in text) + + if error_text: + error_message = error_text.find_next('p') + if error_message: + console.print(f"[red]Upload error: {error_message.text}") + + return False + else: + console.print("[red]Failed to log in to THR for upload") + return False + + except Exception as e: + console.print(f"[red]Error during upload: {str(e)}") + console.print_exception() if meta['debug']: - console.print(response.text) - if response.url.endswith('uploaded=1'): - console.print(f'[green]Successfully Uploaded at: {response.url}') - #Check if actually uploaded - except: - if meta['debug']: - console.print(response.text) - console.print("It may have uploaded, go check") - return + try: + console.print(f"[red]Response: {response.text[:500]}...") + except Exception: + pass + console.print("[yellow]It may have uploaded, please check THR manually") + return False else: - console.print(f"[cyan]Request Data:") + console.print("[cyan]Request Data:") console.print(payload) - - - + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." + return False + async def get_cat_id(self, meta): if meta['category'] == "MOVIE": if meta.get('is_disc') == "BMDV": @@ -133,7 +157,7 @@ async def get_cat_id(self, meta): cat = '7' else: cat = '34' - elif meta.get('anime') != False: + elif meta.get('anime') is not False: cat = '31' return cat @@ -156,30 +180,18 @@ def get_subtitles(self, meta): if sub_langs != []: subs = [] sub_lang_map = { - 'hr' : 1, 'en' : 2, 'bs' : 3, 'sr' : 4, 'sl' : 5, - 'Croatian' : 1, 'English' : 2, 'Bosnian' : 3, 'Serbian' : 4, 'Slovenian' : 5 + 'hr': 1, 'en': 2, 'bs': 3, 'sr': 4, 'sl': 5, + 'Croatian': 1, 'English': 2, 'Bosnian': 3, 'Serbian': 4, 'Slovenian': 5 } for sub in sub_langs: language = sub_lang_map.get(sub) - if language != None: + if language is not None: subs.append(language) return subs - - - - - async def edit_torrent(self, meta): - if os.path.exists(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent"): - THR_torrent = Torrent.read(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent") - THR_torrent.metainfo['announce'] = self.config['TRACKERS']['THR']['announce_url'] - THR_torrent.metainfo['info']['source'] = "[https://www.torrenthr.org] TorrentHR.org" - Torrent.copy(THR_torrent).write(f"{meta['base_dir']}/tmp/{meta['uuid']}/[THR]{meta['clean_name']}.torrent", overwrite=True) - return - async def edit_desc(self, meta): pronfo = False - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8').read() with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[THR]DESCRIPTION.txt", 'w', encoding='utf-8') as desc: if meta['tag'] == "": tag = "" @@ -198,26 +210,38 @@ async def edit_desc(self, meta): desc.write(f"{res} / {meta['type']}{tag}\n\n") desc.write(f"Category: {meta['category']}\n") desc.write(f"TMDB: https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}\n") - if meta['imdb_id'] != "0": - desc.write(f"IMDb: https://www.imdb.com/title/tt{meta['imdb_id']}\n") - if meta['tvdb_id'] != "0": + if meta['imdb_id'] != 0: + desc.write(f"IMDb: https://www.imdb.com/title/tt{meta['imdb']}\n") + if meta['tvdb_id'] != 0: desc.write(f"TVDB: https://www.thetvdb.com/?id={meta['tvdb_id']}&tab=series\n") desc.write("[/quote]") desc.write(base) # REHOST IMAGES os.chdir(f"{meta['base_dir']}/tmp/{meta['uuid']}") - image_glob = glob.glob("*.png") - if 'POSTER.png' in image_glob: - image_glob.remove('POSTER.png') + image_patterns = ["*.png", ".[!.]*.png"] + image_glob = [] + for pattern in image_patterns: + image_glob.extend(glob.glob(pattern)) + + unwanted_patterns = ["FILE*", "PLAYLIST*", "POSTER*"] + unwanted_files = set() + for pattern in unwanted_patterns: + unwanted_files.update(glob.glob(pattern)) + if pattern.startswith("FILE") or pattern.startswith("PLAYLIST") or pattern.startswith("POSTER"): + hidden_pattern = "." + pattern + unwanted_files.update(glob.glob(hidden_pattern)) + + image_glob = [file for file in image_glob if file not in unwanted_files] + image_glob = list(set(image_glob)) image_list = [] for image in image_glob: url = "https://img2.torrenthr.org/api/1/upload" data = { - 'key' : self.config['TRACKERS']['THR'].get('img_api'), + 'key': self.config['TRACKERS']['THR'].get('img_api'), # 'source' : base64.b64encode(open(image, "rb").read()).decode('utf8') } - files = {'source' : open(image, 'rb')} - response = requests.post(url, data = data, files=files) + files = {'source': open(image, 'rb')} + response = requests.post(url, data=data, files=files) try: response = response.json() # med_url = response['image']['medium']['url'] @@ -239,22 +263,22 @@ async def edit_desc(self, meta): # ProNFO pronfo_url = f"https://www.pronfo.com/api/v1/access/upload/{self.config['TRACKERS']['THR'].get('pronfo_api_key', '')}" data = { - 'content' : open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r').read(), - 'theme' : self.config['TRACKERS']['THR'].get('pronfo_theme', 'gray'), - 'rapi' : self.config['TRACKERS']['THR'].get('pronfo_rapi_id') + 'content': open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r').read(), + 'theme': self.config['TRACKERS']['THR'].get('pronfo_theme', 'gray'), + 'rapi': self.config['TRACKERS']['THR'].get('pronfo_rapi_id') } response = requests.post(pronfo_url, data=data) try: response = response.json() - if response.get('error', True) == False: + if response.get('error', True) is False: mi_img = response.get('url') desc.write(f"\n[img]{mi_img}[/img]\n") pronfo = True - except: + except Exception: console.print('[bold red]Error parsing pronfo response, using THR parser instead') if meta['debug']: console.print(f"[red]{response}") - console.print(response.text) + console.print(response.text) for each in image_list[:int(meta['screens'])]: desc.write(f"\n[img]{each}[/img]\n") @@ -267,35 +291,182 @@ async def edit_desc(self, meta): desc.close() return pronfo - + async def search_existing(self, meta, disctype): + imdb_id = meta.get('imdb', '') + base_search_url = f"https://www.torrenthr.org/browse.php?search={imdb_id}&blah=2&incldead=1" + dupes = [] + + if not imdb_id: + console.print("[red]No IMDb ID available for search", style="bold red") + return dupes + try: + cookies = await self.login() + + client_args = {'timeout': 10.0, 'follow_redirects': True} + if cookies: + client_args['cookies'] = cookies + else: + console.print("[red]Failed to log in to THR for search") + return dupes + + async with httpx.AsyncClient(**client_args) as client: + # Start with first page (page 0 in THR's system) + current_page = 0 + more_pages = True + page_count = 0 + all_titles_seen = set() + + while more_pages: + page_url = base_search_url + if current_page > 0: + page_url += f"&page={current_page}" + + page_count += 1 + if meta.get('debug', False): + console.print(f"[dim]Searching page {page_count}...") + response = await client.get(page_url) + + page_dupes, has_next_page, next_page_number = await self._process_search_response( + response, meta, all_titles_seen, current_page) + + for dupe in page_dupes: + if dupe not in dupes: + dupes.append(dupe) + all_titles_seen.add(dupe) + + if meta.get('debug', False) and has_next_page: + console.print(f"[dim]Next page available: page {next_page_number}") + + if has_next_page: + current_page = next_page_number + + await asyncio.sleep(1) + else: + more_pages = False + + except httpx.TimeoutException: + console.print("[bold red]Request timed out while searching for existing torrents.") + except httpx.RequestError as e: + console.print(f"[bold red]An error occurred while making the request: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + console.print_exception() - def search_existing(self, session, imdb_id): - from bs4 import BeautifulSoup - imdb_id = imdb_id.replace('tt', '') - search_url = f"https://www.torrenthr.org/browse.php?search={imdb_id}&blah=2&incldead=1" - search = session.get(search_url) - soup = BeautifulSoup(search.text, 'html.parser') - dupes = [] - for link in soup.find_all('a', href=True): - if link['href'].startswith('details.php'): - if link.get('onmousemove', False): - dupe = link['onmousemove'].split("','/images") - dupe = dupe[0].replace("return overlibImage('", "") - dupes.append(dupe) return dupes - def login(self, session): + async def _process_search_response(self, response, meta, existing_dupes, current_page): + page_dupes = [] + has_next_page = False + next_page_number = current_page + + if response.status_code == 200 or response.status_code == 302: + html_length = len(response.text) + if meta.get('debug', False): + console.print(f"[dim]Response HTML length: {html_length} bytes") + + if html_length < 1000: + console.print(f"[yellow]Response seems too small ({html_length} bytes), might be an error page") + if meta.get('debug', False): + console.print(f"[yellow]Response content: {response.text[:500]}") + return page_dupes, False, current_page + + soup = BeautifulSoup(response.text, 'html.parser') + + result_table = soup.find('table', {'class': 'torrentlist'}) or soup.find('table', {'align': 'center'}) + if not result_table: + console.print("[yellow]No results table found in HTML - either no results or page structure changed") + + link_count = 0 + onmousemove_count = 0 + + for link in soup.find_all('a', href=True): + if link['href'].startswith('details.php'): + link_count += 1 + if link.get('onmousemove', False): + onmousemove_count += 1 + try: + dupe = link['onmousemove'].split("','/images")[0] + dupe = dupe.replace("return overlibImage('", "") + page_dupes.append(dupe) + except Exception as parsing_error: + if meta.get('debug', False): + console.print(f"[yellow]Error parsing link: {parsing_error}") + + page_number_display = current_page + 1 + if meta.get('debug', False): + console.print(f"[dim]Page {page_number_display}: Found {link_count} detail links, {onmousemove_count} parsed successfully") + + pagination_text = None + for p_tag in soup.find_all('p', align="center"): + if p_tag.text and ('Prev' in p_tag.text or 'Next' in p_tag.text): + pagination_text = p_tag + if meta.get('debug', False): + console.print(f"[dim]Found pagination: {pagination_text.text.strip()}") + break + + if pagination_text: + next_links = pagination_text.find_all('a') + for link in next_links: + if 'Next' in link.text: + has_next_page = True + href = link.get('href', '') + + if meta.get('debug', False): + console.print(f"[dim]Next page URL: {href}") + + page_match = re.search(r'page=(\d+)', href) + if page_match: + next_page_number = int(page_match.group(1)) + if meta.get('debug', False): + console.print(f"[dim]Found next page link: page={next_page_number} (will be displayed as page {next_page_number + 1})") + break + else: + console.print(f"[bold red]HTTP request failed. Status: {response.status_code}") + if meta.get('debug', False): + console.print(f"[red]Response: {response.text[:500]}...") + + return page_dupes, has_next_page, next_page_number + + async def login(self): + console.print("[yellow]Logging in to THR...") url = 'https://www.torrenthr.org/takelogin.php' + + if not self.username or not self.password: + console.print('[red]Missing THR credentials in config.py') + return None + payload = { - 'username' : self.username, - 'password' : self.password, - 'ssl' : 'yes' + 'username': self.username, + 'password': self.password, + 'ssl': 'yes' } headers = { - 'User-Agent' : f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' + 'User-Agent': f'Upload Assistant/2.2 ({platform.system()} {platform.release()})', + 'Referer': 'https://www.torrenthr.org/login.php' } - resp = session.post(url, headers=headers, data=payload) - if resp.url == "https://www.torrenthr.org/index.php": - console.print('[green]Successfully logged in') - return session + + async with httpx.AsyncClient(follow_redirects=True) as session: + try: + login_page = await session.get('https://www.torrenthr.org/login.php') + login_soup = BeautifulSoup(login_page.text, 'html.parser') + + for input_tag in login_soup.find_all('input', type='hidden'): + if input_tag.get('name') and input_tag.get('value'): + payload[input_tag['name']] = input_tag['value'] + + resp = await session.post(url, headers=headers, data=payload) + + if "index.php" in str(resp.url) or "logout.php" in resp.text: + console.print('[green]Successfully logged in to THR') + return dict(session.cookies) + else: + console.print('[red]Failed to log in to THR') + console.print(f'[red]Login response URL: {resp.url}') + console.print(f'[red]Login status code: {resp.status_code}') + return None + + except Exception as e: + console.print(f"[red]Error during THR login: {str(e)}") + console.print_exception() + return None diff --git a/src/trackers/TIK.py b/src/trackers/TIK.py new file mode 100644 index 000000000..a6b9a461e --- /dev/null +++ b/src/trackers/TIK.py @@ -0,0 +1,456 @@ +# -*- coding: utf-8 -*- +# import discord +import aiofiles +import click +import os +import re +import urllib.request +from src.console import console +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D +from src.uploadscreens import upload_screens + + +class TIK(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='TIK') + self.config = config + self.common = COMMON(config) + self.tracker = 'TIK' + self.source_flag = 'TIK' + self.base_url = 'https://cinematik.net' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_additional_checks(self, meta): + should_continue = True + + if not meta['is_disc']: + console.print("[red]Only disc-based content allowed at TIK") + return False + + return should_continue + + async def get_additional_data(self, meta): + data = { + 'modq': await self.get_flag(meta, 'modq'), + } + + return data + + async def get_name(self, meta): + disctype = meta.get('disctype', None) + basename = os.path.basename(next(iter(meta['filelist']), meta['path'])) + type = meta.get('type', "") + title = meta.get('title', "").replace('AKA', '/').strip() + alt_title = meta.get('aka', "").replace('AKA', '/').strip() + year = meta.get('year', "") + resolution = meta.get('resolution', "") + season = meta.get('season', "") + repack = meta.get('repack', "") + if repack.strip(): + repack = f"[{repack}]" + three_d = meta.get('3D', "") + three_d_tag = f"[{three_d}]" if three_d else "" + tag = meta.get('tag', "").replace("-", "- ") + if tag == "": + tag = "- NOGRP" + source = meta.get('source', "") + uhd = meta.get('uhd', "") # noqa #841 + hdr = meta.get('hdr', "") + if not hdr.strip(): + hdr = "SDR" + distributor = meta.get('distributor', "") # noqa F841 + video_codec = meta.get('video_codec', "") + video_encode = meta.get('video_encode', "").replace(".", "") + if 'x265' in basename: + video_encode = video_encode.replace('H', 'x') + dvd_size = meta.get('dvd_size', "") + search_year = meta.get('search_year', "") + if not str(search_year).strip(): + search_year = year + meta['category_id'] = (await self.get_category_id(meta))['category_id'] + + name = "" + alt_title_part = f" {alt_title}" if alt_title else "" + if meta['category_id'] in ("1", "3", "5", "6"): + if meta['is_disc'] == 'BDMV': + name = f"{title}{alt_title_part} ({year}) {disctype} {resolution} {video_codec} {three_d_tag}" + elif meta['is_disc'] == 'DVD': + name = f"{title}{alt_title_part} ({year}) {source} {dvd_size}" + elif meta['category'] == "TV": # TV SPECIFIC + if type == "DISC": # Disk + if meta['is_disc'] == 'BDMV': + name = f"{title}{alt_title_part} ({search_year}) {season} {disctype} {resolution} {video_codec}" + if meta['is_disc'] == 'DVD': + name = f"{title}{alt_title_part} ({search_year}) {season} {source} {dvd_size}" + + return {'name': name} + + async def get_category_id(self, meta): + category_name = meta['category'] + foreign = meta.get('foreign', False) + opera = meta.get('opera', False) + asian = meta.get('asian', False) + category_id = { + 'FILM': '1', + 'TV': '2', + 'Foreign Film': '3', + 'Foreign TV': '4', + 'Opera & Musical': '5', + 'Asian Film': '6', + }.get(category_name, '0') + + if category_name == 'MOVIE': + if foreign: + category_id = '3' + elif opera: + category_id = '5' + elif asian: + category_id = '6' + else: + category_id = '1' + elif category_name == 'TV': + if foreign: + category_id = '4' + elif opera: + category_id = '5' + else: + category_id = '2' + + return {'category_id': category_id} + + async def get_type_id(self, meta): + disctype = meta.get('disctype', None) + type_id_map = { + 'Custom': '1', + 'BD100': '3', + 'BD66': '4', + 'BD50': '5', + 'BD25': '6', + 'NTSC DVD9': '7', + 'NTSC DVD5': '8', + 'PAL DVD9': '9', + 'PAL DVD5': '10', + '3D': '11' + } + + if not disctype: + console.print("[red]You must specify a --disctype") + # Raise an exception since we can't proceed without disctype + raise ValueError("disctype is required for TIK tracker but was not provided") + + disctype_value = disctype[0] if isinstance(disctype, list) else disctype + type_id = type_id_map.get(disctype_value, '1') # '1' is the default fallback + + return {'type_id': type_id} + + async def get_resolution_id(self, meta): + resolution_id = { + 'Other': '10', + '4320p': '1', + '2160p': '2', + '1440p': '3', + '1080p': '3', + '1080i': '4', + '720p': '5', + '576p': '6', + '576i': '7', + '480p': '8', + '480i': '9' + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} + + async def get_description(self, meta): + await self.common.unit3d_edit_desc(meta, self.tracker, self.signature, comparison=True) + if meta.get('desclink') or meta.get('descfile'): + async with aiofiles.open(f'{meta["base_dir"]}/tmp/{meta["uuid"]}/[{self.tracker}]DESCRIPTION.txt', 'r', encoding='utf-8') as f: + desc = await f.read() + + print(f'Custom Description Link/File Path: {desc}') + return {'description': desc} + + if len(meta.get('discs', [])) > 0: + summary = meta['discs'][0].get('summary', '') + else: + summary = None + + # Proceed with matching Total Bitrate if the summary exists + if summary: + match = re.search(r"Total Bitrate: ([\d.]+ Mbps)", summary) + if match: + total_bitrate = match.group(1) + else: + total_bitrate = "Unknown" + else: + total_bitrate = "Unknown" + + country_name = self.country_code_to_name(meta.get('region')) + + # Rehost poster if tmdb_poster is available + poster_url = f"https://image.tmdb.org/t/p/original{meta.get('tmdb_poster', '')}" + + # Define the paths for both jpg and png poster images + poster_jpg_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/poster.jpg" + poster_png_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/poster.png" + + # Check if either poster.jpg or poster.png already exists + if os.path.exists(poster_jpg_path): + poster_path = poster_jpg_path + console.print("[green]Poster already exists as poster.jpg, skipping download.[/green]") + elif os.path.exists(poster_png_path): + poster_path = poster_png_path + console.print("[green]Poster already exists as poster.png, skipping download.[/green]") + else: + # No poster file exists, download the poster image + poster_path = poster_jpg_path # Default to saving as poster.jpg + try: + urllib.request.urlretrieve(poster_url, poster_path) + console.print(f"[green]Poster downloaded to {poster_path}[/green]") + except Exception as e: + console.print(f"[red]Error downloading poster: {e}[/red]") + + # Upload the downloaded or existing poster image once + if os.path.exists(poster_path): + try: + console.print("Uploading standard poster to image host....") + new_poster_url, _ = await upload_screens(meta, 1, 1, 0, 1, [poster_path], {}) + + # Ensure that the new poster URL is assigned only once + if len(new_poster_url) > 0: + poster_url = new_poster_url[0]['raw_url'] + except Exception as e: + console.print(f"[red]Error uploading poster: {e}[/red]") + else: + console.print("[red]Poster file not found, cannot upload.[/red]") + + # Generate the description text + desc_text = [] + + images = meta['image_list'] + discs = meta.get('discs', []) # noqa #F841 + + if len(images) >= 6: + image_link_1 = images[0]['raw_url'] + image_link_2 = images[1]['raw_url'] + image_link_3 = images[2]['raw_url'] + image_link_4 = images[3]['raw_url'] + image_link_5 = images[4]['raw_url'] + image_link_6 = images[5]['raw_url'] + else: + image_link_1 = image_link_2 = image_link_3 = image_link_4 = image_link_5 = image_link_6 = "" + + # Write the cover section with rehosted poster URL + desc_text.append("[h3]Cover[/h3] [color=red]A stock poster has been automatically added, but you'll get more love if you include a proper cover, see rule 6.6[/color]\n") + desc_text.append("[center]\n") + desc_text.append(f"[IMG=500]{poster_url}[/IMG]\n") + desc_text.append("[/center]\n\n") + + # Write screenshots section + desc_text.append("[h3]Screenshots[/h3]\n") + desc_text.append("[center]\n") + desc_text.append(f"[URL={image_link_1}][IMG=300]{image_link_1}[/IMG][/URL] ") + desc_text.append(f"[URL={image_link_2}][IMG=300]{image_link_2}[/IMG][/URL] ") + desc_text.append(f"[URL={image_link_3}][IMG=300]{image_link_3}[/IMG][/URL]\n ") + desc_text.append(f"[URL={image_link_4}][IMG=300]{image_link_4}[/IMG][/URL] ") + desc_text.append(f"[URL={image_link_5}][IMG=300]{image_link_5}[/IMG][/URL] ") + desc_text.append(f"[URL={image_link_6}][IMG=300]{image_link_6}[/IMG][/URL]\n") + desc_text.append("[/center]\n\n") + + # Write synopsis section with the custom title + desc_text.append("[h3]Synopsis/Review/Personal Thoughts (edit as needed)[/h3]\n") + desc_text.append("[color=red]Default TMDB sypnosis added, more love if you use a sypnosis from credible film institutions such as the BFI or directly quoting well-known film critics, see rule 6.3[/color]\n") + desc_text.append("[quote]\n") + desc_text.append(f"{meta.get('overview', 'No synopsis available.')}\n") + desc_text.append("[/quote]\n\n") + + # Write technical info section + desc_text.append("[h3]Technical Info[/h3]\n") + desc_text.append("[code]\n") + if meta['is_disc'] == 'BDMV': + desc_text.append(f" Disc Label.........:{meta.get('bdinfo', {}).get('label', '')}\n") + desc_text.append(f" IMDb...............: [url]https://www.imdb.com/title/tt{meta.get('imdb_id')}{meta.get('imdb_rating', '')}[/url]\n") + desc_text.append(f" Year...............: {meta.get('year', '')}\n") + desc_text.append(f" Country............: {country_name}\n") + if meta['is_disc'] == 'BDMV': + desc_text.append(f" Runtime............: {meta.get('bdinfo', {}).get('length', '')} hrs [color=red](double check this is actual runtime)[/color]\n") + else: + desc_text.append(" Runtime............: [color=red]Insert the actual runtime[/color]\n") + + if meta['is_disc'] == 'BDMV': + audio_languages = ', '.join([f"{track.get('language', 'Unknown')} {track.get('codec', 'Unknown')} {track.get('channels', 'Unknown')}" for track in meta.get('bdinfo', {}).get('audio', [])]) + desc_text.append(f" Audio..............: {audio_languages}\n") + desc_text.append(f" Subtitles..........: {', '.join(meta.get('bdinfo', {}).get('subtitles', []))}\n") + else: + # Process each disc's `vob_mi` or `ifo_mi` to extract audio and subtitles separately + for disc in meta.get('discs', []): + vob_mi = disc.get('vob_mi', '') + ifo_mi = disc.get('ifo_mi', '') + + unique_audio = set() # Store unique audio strings + + audio_section = vob_mi.split('\n\nAudio\n')[1].split('\n\n')[0] if 'Audio\n' in vob_mi else None + if audio_section: + if "AC-3" in audio_section: + codec = "AC-3" + elif "DTS" in audio_section: + codec = "DTS" + elif "MPEG Audio" in audio_section: + codec = "MPEG Audio" + elif "PCM" in audio_section: + codec = "PCM" + elif "AAC" in audio_section: + codec = "AAC" + else: + codec = "Unknown" + + channels = audio_section.split("Channel(s)")[1].split(":")[1].strip().split(" ")[0] if "Channel(s)" in audio_section else "Unknown" + # Convert 6 channels to 5.1, otherwise leave as is + channels = "5.1" if channels == "6" else channels + language = disc.get('ifo_mi_full', '').split('Language')[1].split(":")[1].strip().split('\n')[0] if "Language" in disc.get('ifo_mi_full', '') else "Unknown" + audio_info = f"{language} {codec} {channels}" + unique_audio.add(audio_info) + + # Append audio information to the description + if unique_audio: + desc_text.append(f" Audio..............: {', '.join(sorted(unique_audio))}\n") + + # Subtitle extraction using the helper function + unique_subtitles = self.parse_subtitles(ifo_mi) + + # Append subtitle information to the description + if unique_subtitles: + desc_text.append(f" Subtitles..........: {', '.join(sorted(unique_subtitles))}\n") + + if meta['is_disc'] == 'BDMV': + video_info = meta.get('bdinfo', {}).get('video', []) + video_codec = video_info[0].get('codec', 'Unknown') + video_bitrate = video_info[0].get('bitrate', 'Unknown') + desc_text.append(f" Video Format.......: {video_codec} / {video_bitrate}\n") + else: + desc_text.append(f" DVD Format.........: {meta.get('source', 'Unknown')}\n") + desc_text.append(" Film Aspect Ratio..: [color=red]The actual aspect ratio of the content, not including the black bars[/color]\n") + if meta['is_disc'] == 'BDMV': + desc_text.append(f" Source.............: {meta.get('disctype', 'Unknown')}\n") + else: + desc_text.append(f" Source.............: {meta.get('dvd_size', 'Unknown')}\n") + desc_text.append(f" Film Distributor...: [url={meta.get('distributor_link', '')}]{meta.get('distributor', 'Unknown')}[/url] [color=red]Don't forget the actual distributor link\n") + desc_text.append(f" Average Bitrate....: {total_bitrate}\n") + desc_text.append(" Ripping Program....: [color=red]Specify - if it's your rip or custom version, otherwise 'Not my rip'[/color]\n") + desc_text.append("\n") + if meta.get('untouched') is True: + desc_text.append(" Menus......: [X] Untouched\n") + desc_text.append(" Video......: [X] Untouched\n") + desc_text.append(" Extras.....: [X] Untouched\n") + desc_text.append(" Audio......: [X] Untouched\n") + else: + desc_text.append(" Menus......: [ ] Untouched\n") + desc_text.append(" [ ] Stripped\n") + desc_text.append(" Video......: [ ] Untouched\n") + desc_text.append(" [ ] Re-encoded\n") + desc_text.append(" Extras.....: [ ] Untouched\n") + desc_text.append(" [ ] Stripped\n") + desc_text.append(" [ ] Re-encoded\n") + desc_text.append(" [ ] None\n") + desc_text.append(" Audio......: [ ] Untouched\n") + desc_text.append(" [ ] Stripped tracks\n") + + desc_text.append("[/code]\n\n") + + # Extras + desc_text.append("[h4]Extras[/h4]\n") + desc_text.append("[*] Insert special feature 1 here\n") + desc_text.append("[*] Insert special feature 2 here\n") + desc_text.append("... (add more special features as needed)\n\n") + + # Uploader Comments + desc_text.append("[h4]Uploader Comments[/h4]\n") + desc_text.append(f" - {meta.get('uploader_comments', 'No comments.')}\n") + + # Convert the list to a single string for the description + description = ''.join(desc_text) + + # Ask user if they want to edit or keep the description + console.print(f"Current description: {description}", markup=False) + console.print("[cyan]Do you want to edit or keep the description?[/cyan]") + edit_choice = input("Enter 'e' to edit, or press Enter to keep it as is: ") + + if edit_choice.lower() == 'e': + edited_description = click.edit(description) + if edited_description: + description = edited_description.strip() + console.print(f"Final description after editing: {description}", markup=False) + else: + console.print("[green]Keeping the original description.[/green]") + + # Write the final description to the file + async with aiofiles.open(f'{meta["base_dir"]}/tmp/{meta["uuid"]}/[{self.tracker}]DESCRIPTION.txt', 'w', encoding='utf-8') as desc_file: + await desc_file.write(description) + + return {'description': description} + + def parse_subtitles(self, disc_mi): + unique_subtitles = set() # Store unique subtitle strings + lines = disc_mi.splitlines() # Split the multiline text into individual lines + current_block = None + + for line in lines: + # Detect the start of a subtitle block (Text #) + if line.startswith("Text #"): + current_block = "subtitle" + continue + + # Extract language information for subtitles + if current_block == "subtitle" and "Language" in line: + language = line.split(":")[1].strip() + unique_subtitles.add(language) + + return unique_subtitles + + def country_code_to_name(self, code): + country_mapping = { + 'AFG': 'Afghanistan', 'ALB': 'Albania', 'DZA': 'Algeria', 'AND': 'Andorra', 'AGO': 'Angola', + 'ARG': 'Argentina', 'ARM': 'Armenia', 'AUS': 'Australia', 'AUT': 'Austria', 'AZE': 'Azerbaijan', + 'BHS': 'Bahamas', 'BHR': 'Bahrain', 'BGD': 'Bangladesh', 'BRB': 'Barbados', 'BLR': 'Belarus', + 'BEL': 'Belgium', 'BLZ': 'Belize', 'BEN': 'Benin', 'BTN': 'Bhutan', 'BOL': 'Bolivia', + 'BIH': 'Bosnia and Herzegovina', 'BWA': 'Botswana', 'BRA': 'Brazil', 'BRN': 'Brunei', + 'BGR': 'Bulgaria', 'BFA': 'Burkina Faso', 'BDI': 'Burundi', 'CPV': 'Cabo Verde', 'KHM': 'Cambodia', + 'CMR': 'Cameroon', 'CAN': 'Canada', 'CAF': 'Central African Republic', 'TCD': 'Chad', 'CHL': 'Chile', + 'CHN': 'China', 'COL': 'Colombia', 'COM': 'Comoros', 'COG': 'Congo', 'CRI': 'Costa Rica', + 'HRV': 'Croatia', 'CUB': 'Cuba', 'CYP': 'Cyprus', 'CZE': 'Czech Republic', 'DNK': 'Denmark', + 'DJI': 'Djibouti', 'DMA': 'Dominica', 'DOM': 'Dominican Republic', 'ECU': 'Ecuador', 'EGY': 'Egypt', + 'SLV': 'El Salvador', 'GNQ': 'Equatorial Guinea', 'ERI': 'Eritrea', 'EST': 'Estonia', + 'SWZ': 'Eswatini', 'ETH': 'Ethiopia', 'FJI': 'Fiji', 'FIN': 'Finland', 'FRA': 'France', + 'GAB': 'Gabon', 'GMB': 'Gambia', 'GEO': 'Georgia', 'DEU': 'Germany', 'GHA': 'Ghana', + 'GRC': 'Greece', 'GRD': 'Grenada', 'GTM': 'Guatemala', 'GIN': 'Guinea', 'GNB': 'Guinea-Bissau', + 'GUY': 'Guyana', 'HTI': 'Haiti', 'HND': 'Honduras', 'HUN': 'Hungary', 'ISL': 'Iceland', 'IND': 'India', + 'IDN': 'Indonesia', 'IRN': 'Iran', 'IRQ': 'Iraq', 'IRL': 'Ireland', 'ISR': 'Israel', 'ITA': 'Italy', + 'JAM': 'Jamaica', 'JPN': 'Japan', 'JOR': 'Jordan', 'KAZ': 'Kazakhstan', 'KEN': 'Kenya', + 'KIR': 'Kiribati', 'KOR': 'Korea', 'KWT': 'Kuwait', 'KGZ': 'Kyrgyzstan', 'LAO': 'Laos', 'LVA': 'Latvia', + 'LBN': 'Lebanon', 'LSO': 'Lesotho', 'LBR': 'Liberia', 'LBY': 'Libya', 'LIE': 'Liechtenstein', + 'LTU': 'Lithuania', 'LUX': 'Luxembourg', 'MDG': 'Madagascar', 'MWI': 'Malawi', 'MYS': 'Malaysia', + 'MDV': 'Maldives', 'MLI': 'Mali', 'MLT': 'Malta', 'MHL': 'Marshall Islands', 'MRT': 'Mauritania', + 'MUS': 'Mauritius', 'MEX': 'Mexico', 'FSM': 'Micronesia', 'MDA': 'Moldova', 'MCO': 'Monaco', + 'MNG': 'Mongolia', 'MNE': 'Montenegro', 'MAR': 'Morocco', 'MOZ': 'Mozambique', 'MMR': 'Myanmar', + 'NAM': 'Namibia', 'NRU': 'Nauru', 'NPL': 'Nepal', 'NLD': 'Netherlands', 'NZL': 'New Zealand', + 'NIC': 'Nicaragua', 'NER': 'Niger', 'NGA': 'Nigeria', 'MKD': 'North Macedonia', 'NOR': 'Norway', + 'OMN': 'Oman', 'PAK': 'Pakistan', 'PLW': 'Palau', 'PAN': 'Panama', 'PNG': 'Papua New Guinea', + 'PRY': 'Paraguay', 'PER': 'Peru', 'PHL': 'Philippines', 'POL': 'Poland', 'PRT': 'Portugal', + 'QAT': 'Qatar', 'ROU': 'Romania', 'RUS': 'Russia', 'RWA': 'Rwanda', 'KNA': 'Saint Kitts and Nevis', + 'LCA': 'Saint Lucia', 'VCT': 'Saint Vincent and the Grenadines', 'WSM': 'Samoa', 'SMR': 'San Marino', + 'STP': 'Sao Tome and Principe', 'SAU': 'Saudi Arabia', 'SEN': 'Senegal', 'SRB': 'Serbia', + 'SYC': 'Seychelles', 'SLE': 'Sierra Leone', 'SGP': 'Singapore', 'SVK': 'Slovakia', 'SVN': 'Slovenia', + 'SLB': 'Solomon Islands', 'SOM': 'Somalia', 'ZAF': 'South Africa', 'SSD': 'South Sudan', + 'ESP': 'Spain', 'LKA': 'Sri Lanka', 'SDN': 'Sudan', 'SUR': 'Suriname', 'SWE': 'Sweden', + 'CHE': 'Switzerland', 'SYR': 'Syria', 'TWN': 'Taiwan', 'TJK': 'Tajikistan', 'TZA': 'Tanzania', + 'THA': 'Thailand', 'TLS': 'Timor-Leste', 'TGO': 'Togo', 'TON': 'Tonga', 'TTO': 'Trinidad and Tobago', + 'TUN': 'Tunisia', 'TUR': 'Turkey', 'TKM': 'Turkmenistan', 'TUV': 'Tuvalu', 'UGA': 'Uganda', + 'UKR': 'Ukraine', 'ARE': 'United Arab Emirates', 'GBR': 'United Kingdom', 'USA': 'United States', + 'URY': 'Uruguay', 'UZB': 'Uzbekistan', 'VUT': 'Vanuatu', 'VEN': 'Venezuela', 'VNM': 'Vietnam', + 'YEM': 'Yemen', 'ZMB': 'Zambia', 'ZWE': 'Zimbabwe' + } + return country_mapping.get(code.upper(), 'Unknown Country') diff --git a/src/trackers/TL.py b/src/trackers/TL.py index 9b98f602f..45d9d95bc 100644 --- a/src/trackers/TL.py +++ b/src/trackers/TL.py @@ -1,14 +1,15 @@ # -*- coding: utf-8 -*- # import discord -import requests +import httpx +import os +import re import platform - from src.trackers.COMMON import COMMON from src.console import console -from pathlib import Path +from pymediainfo import MediaInfo -class TL(): +class TL: CATEGORIES = { 'Anime': 34, 'Movie4K': 47, @@ -30,18 +31,137 @@ class TL(): def __init__(self, config): self.config = config + self.common = COMMON(config) self.tracker = 'TL' self.source_flag = 'TorrentLeech.org' - self.upload_url = 'https://www.torrentleech.org/torrents/upload/apiupload' - self.signature = None - self.banned_groups = [""] - - self.announce_key = self.config['TRACKERS'][self.tracker]['announce_key'] - self.config['TRACKERS'][self.tracker]['announce_url'] = f"https://tracker.torrentleech.org/a/{self.announce_key}/announce" - pass - + self.base_url = 'https://www.torrentleech.org' + self.http_upload_url = f'{self.base_url}/torrents/upload/' + self.api_upload_url = f'{self.base_url}/torrents/upload/apiupload' + self.torrent_url = f'{self.base_url}/torrent/' + self.ua_name = f'Upload Assistant {self.common.get_version()}'.strip() + self.signature = f"""
Created by {self.ua_name}
""" + self.banned_groups = [] + self.session = httpx.AsyncClient(timeout=60.0) + self.api_upload = self.config['TRACKERS'][self.tracker].get('api_upload', False) + self.passkey = self.config['TRACKERS'][self.tracker]['passkey'] + self.announce_url_1 = f'https://tracker.torrentleech.org/a/{self.passkey}/announce' + self.announce_url_2 = f'https://tracker.tleechreload.org/a/{self.passkey}/announce' + self.session.headers.update({ + 'User-Agent': f'{self.ua_name} ({platform.system()} {platform.release()})' + }) + + async def login(self, meta, force=False): + if self.api_upload and not force: + return True + + self.cookies_file = os.path.abspath(f"{meta['base_dir']}/data/cookies/TL.txt") + + cookie_path = os.path.abspath(self.cookies_file) + if not os.path.exists(cookie_path): + console.print(f"[bold red]'{self.tracker}' Cookies not found at: {cookie_path}[/bold red]") + return False + + common = COMMON(config=self.config) + self.session.cookies.update(await common.parseCookieFile(self.cookies_file)) + + try: + if force: + response = await self.session.get('https://www.torrentleech.org/torrents/browse/index', timeout=10) + if response.status_code == 301 and 'torrents/browse' in str(response.url): + if meta['debug']: + console.print(f"[bold green]Logged in to '{self.tracker}' with cookies.[/bold green]") + return True + elif not force: + response = await self.session.get(self.http_upload_url, timeout=10) + if response.status_code == 200 and 'torrents/upload' in str(response.url): + if meta['debug']: + console.print(f"[bold green]Logged in to '{self.tracker}' with cookies.[/bold green]") + return True + else: + console.print(f"[bold red]Login to '{self.tracker}' with cookies failed. Please check your cookies.[/bold red]") + return False + + except httpx.RequestError as e: + console.print(f"[bold red]Error while validating credentials for '{self.tracker}': {e}[/bold red]") + return False + + async def generate_description(self, meta): + base_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt" + self.final_desc_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + description_parts = [] + + # MediaInfo/BDInfo + tech_info = "" + if meta.get('is_disc') != 'BDMV': + video_file = meta['filelist'][0] + mi_template = os.path.abspath(f"{meta['base_dir']}/data/templates/MEDIAINFO.txt") + if os.path.exists(mi_template): + try: + media_info = MediaInfo.parse(video_file, output="STRING", full=False, mediainfo_options={"inform": f"file://{mi_template}"}) + tech_info = str(media_info) + except Exception: + console.print("[bold red]Couldn't find the MediaInfo template[/bold red]") + mi_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + if os.path.exists(mi_file_path): + with open(mi_file_path, 'r', encoding='utf-8') as f: + tech_info = f.read() + else: + console.print("[bold yellow]Using normal MediaInfo for the description.[/bold yellow]") + mi_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO_CLEANPATH.txt" + if os.path.exists(mi_file_path): + with open(mi_file_path, 'r', encoding='utf-8') as f: + tech_info = f.read() + else: + bd_summary_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt" + if os.path.exists(bd_summary_file): + with open(bd_summary_file, 'r', encoding='utf-8') as f: + tech_info = f.read() + + if tech_info: + description_parts.append(tech_info) + + if os.path.exists(base_desc_path): + with open(base_desc_path, 'r', encoding='utf-8') as f: + manual_desc = f.read() + description_parts.append(manual_desc) + + # Add screenshots to description only if it is an anonymous upload as TL does not support anonymous upload in the screenshots section + if meta.get('anon', False) or self.api_upload: + images = meta.get('image_list', []) + + screenshots_block = "
Screenshots\n\n" + for image in images: + img_url = image['img_url'] + web_url = image['web_url'] + screenshots_block += f""" """ + screenshots_block += "\n
" + + description_parts.append(screenshots_block) + + if self.signature: + description_parts.append(self.signature) + + final_description = "\n\n".join(filter(None, description_parts)) + from src.bbcode import BBCODE + bbcode = BBCODE() + desc = final_description + desc = desc.replace("[center]", "
").replace("[/center]", "
") + desc = re.sub(r'\[spoiler=.*?\]', '[spoiler]', desc, flags=re.IGNORECASE) + desc = re.sub(r'\[\*\]', '\n[*]', desc, flags=re.IGNORECASE) + desc = re.sub(r'\[list=.*?\]', '[list]', desc, flags=re.IGNORECASE) + desc = re.sub(r'\[c\](.*?)\[/c\]', r'[code]\1[/code]', desc, flags=re.IGNORECASE | re.DOTALL) + desc = re.sub(r'\[hr\]', '---', desc, flags=re.IGNORECASE) + desc = re.sub(r'\[img=[\d"x]+\]', '[img]', desc, flags=re.IGNORECASE) + desc = bbcode.convert_comparison_to_centered(desc, 1000) + + with open(self.final_desc_path, 'w', encoding='utf-8') as f: + f.write(desc) + + return desc + async def get_cat_id(self, common, meta): - if meta.get('anime', 0): + if meta.get('anime', 0): return self.CATEGORIES['Anime'] if meta['category'] == 'MOVIE': @@ -49,7 +169,7 @@ async def get_cat_id(self, common, meta): return self.CATEGORIES['MovieForeign'] elif 'Documentary' in meta['genres']: return self.CATEGORIES['MovieDocumentary'] - elif meta['uhd']: + elif meta['resolution'] == '2160p': return self.CATEGORIES['Movie4K'] elif meta['is_disc'] in ('BDMV', 'HDDVD') or (meta['type'] == 'REMUX' and meta['source'] in ('BluRay', 'HDDVD')): return self.CATEGORIES['MovieBluray'] @@ -64,7 +184,7 @@ async def get_cat_id(self, common, meta): elif meta['type'] == 'HDTV': return self.CATEGORIES['MovieHdRip'] elif meta['category'] == 'TV': - if meta['original_language'] != 'en': + if meta['original_language'] != 'en': return self.CATEGORIES['TvForeign'] elif meta.get('tv_pack', 0): return self.CATEGORIES['TvBoxsets'] @@ -75,44 +195,224 @@ async def get_cat_id(self, common, meta): raise NotImplementedError('Failed to determine TL category!') - async def upload(self, meta): + def get_screens(self, meta): + screenshot_urls = [ + image.get('raw_url') + for image in meta.get('image_list', []) + if image.get('raw_url') + ] + + return screenshot_urls + + def get_name(self, meta): + is_scene = bool(meta.get('scene_name')) + if is_scene: + name = meta['scene_name'] + else: + name = meta['name'] + + return name + + async def search_existing(self, meta, disctype): + login = await self.login(meta, force=True) + if not login: + meta['skipping'] = "TL" + if meta['debug']: + console.print(f"[bold red]Skipping upload to '{self.tracker}' as login failed.[/bold red]") + return + cat_id = await self.get_cat_id(self, meta) + + results = [] + + search_name = meta["title"] + resolution = meta["resolution"] + year = meta['year'] + episode = meta.get('episode', '') + season = meta.get('season', '') + season_episode = f"{season}{episode}" if season or episode else '' + + search_urls = [] + + if meta['category'] == 'TV': + if meta.get('tv_pack', False): + param = f"{cat_id}/query/{search_name} {season} {resolution}" + search_urls.append(f"{self.base_url}/torrents/browse/list/categories/{param}") + else: + episode_param = f"{cat_id}/query/{search_name} {season_episode} {resolution}" + search_urls.append(f"{self.base_url}/torrents/browse/list/categories/{episode_param}") + + # Also check for season packs + pack_cat_id = 44 if cat_id == 44 else 27 # Foreign TV shows do not have a separate cat_id for season/episodes + pack_param = f"{pack_cat_id}/query/{search_name} {season} {resolution}" + search_urls.append(f"{self.base_url}/torrents/browse/list/categories/{pack_param}") + + elif meta['category'] == 'MOVIE': + param = f"{cat_id}/query/{search_name} {year} {resolution}" + search_urls.append(f"{self.base_url}/torrents/browse/list/categories/{param}") + + for url in search_urls: + try: + response = await self.session.get(url, timeout=20) + response.raise_for_status() + + data = response.json() + torrents = data.get("torrentList", []) + + for torrent in torrents: + name = torrent.get('name') + link = f"{self.torrent_url}{torrent.get('fid')}" + size = torrent.get('size') + if name: + results.append({ + 'name': name, + 'size': size, + 'link': link + }) + + except Exception as e: + console.print(f"[bold red]Error searching for duplicates on {self.tracker} ({url}): {e}[/bold red]") + + return results + + async def upload(self, meta, disctype): common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) + await self.common.edit_torrent(meta, self.tracker, self.source_flag, announce_url=self.announce_url_1) cat_id = await self.get_cat_id(common, meta) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - - open_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'a+') - - info_filename = 'BD_SUMMARY_00' if meta['bdinfo'] != None else 'MEDIAINFO_CLEANPATH' - open_info = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/{info_filename}.txt", 'r', encoding='utf-8') - open_desc.write('\n\n') - open_desc.write(open_info.read()) - open_info.close() - - open_desc.seek(0) - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = { - 'nfo': open_desc, - 'torrent': (self.get_name(meta) + '.torrent', open_torrent) - } + + if self.api_upload: + await self.upload_api(meta, cat_id) + else: + await self.upload_http(meta, cat_id) + + async def upload_api(self, meta, cat_id): + desc_content = await self.generate_description(meta) + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + + with open(torrent_path, 'rb') as open_torrent: + files = { + 'torrent': (self.get_name(meta) + '.torrent', open_torrent, 'application/x-bittorrent') + } + data = { + 'announcekey': self.passkey, + 'category': cat_id, + 'description': desc_content + } + + if meta['debug'] is False: + response = await self.session.post( + url=self.api_upload_url, + files=files, + data=data + ) + if not response.text.isnumeric(): + meta['tracker_status'][self.tracker]['status_message'] = "data error: " + response.text + + if response.text.isnumeric(): + meta['tracker_status'][self.tracker]['status_message'] = f"{self.torrent_url}{response.text}" + meta['tracker_status'][self.tracker]['torrent_id'] = response.text + await self.api_torrent_download(meta, self.passkey, response.text) + + else: + console.print("[cyan]Request Data:") + console.print(data) + + async def upload_http(self, meta, cat_id): + login = await self.login(meta) + if not login: + meta['tracker_status'][self.tracker]['status_message'] = "data error: Login with cookies failed." + return + + await self.generate_description(meta) + + imdbURL = '' + if meta.get('category') == 'MOVIE' and meta.get('imdb_info', {}).get('imdbID', ''): + imdbURL = f"https://www.imdb.com/title/{meta.get('imdb_info', {}).get('imdbID', '')}" + + tvMazeURL = '' + if meta.get('category') == 'TV' and meta.get("tvmaze_id"): + tvMazeURL = f"https://www.tvmaze.com/shows/{meta.get('tvmaze_id')}" + + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + torrent_file = f"[{self.tracker}].torrent" + description_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt" + + with open(torrent_path, 'rb') as torrent_fh, open(description_path, 'rb') as nfo: + + files = { + 'torrent': (torrent_file, torrent_fh, 'application/x-bittorrent'), + 'nfo': (f"[{self.tracker}]DESCRIPTION.txt", nfo, 'text/plain') + } + + data = { + 'name': self.get_name(meta), + 'category': cat_id, + 'nonscene': 'on' if not meta.get("scene") else 'off', + 'imdbURL': imdbURL, + 'tvMazeURL': tvMazeURL, + 'igdbURL': '', + 'torrentNFO': '0', + 'torrentDesc': '1', + 'nfotextbox': '', + 'torrentComment': '0', + 'uploaderComments': '', + 'is_anonymous_upload': 'on' if meta.get('anon', False) else 'off', + 'screenshots[]': '' if meta.get('anon', False) else self.get_screens(meta), # It is not possible to upload screenshots anonymously + } + + if meta['debug'] is False: + try: + response = await self.session.post( + url=self.http_upload_url, + files=files, + data=data + ) + + if response.status_code == 302 and 'location' in response.headers: + torrent_id = response.headers['location'].replace('/successfulupload?torrentID=', '') + torrent_url = f"{self.base_url}/torrent/{torrent_id}" + meta['tracker_status'][self.tracker]['status_message'] = torrent_url + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + + announce_list = [ + self.announce_url_1, + self.announce_url_2 + ] + common = COMMON(config=self.config) + await common.add_tracker_torrent(meta, self.tracker, self.source_flag, announce_list, torrent_url) + + else: + console.print("[bold red]Upload failed: No success redirect found.[/bold red]") + failure_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]FailedUpload.html" + with open(failure_path, "w", encoding="utf-8") as f: + f.write(f"Status Code: {response.status_code}\n") + f.write(f"Headers: {response.headers}\n") + f.write(response.text) + console.print(f"[yellow]The response was saved at: '{failure_path}'[/yellow]") + + except httpx.RequestError as e: + console.print(f"[bold red]Error during upload to '{self.tracker}': {e}[/bold red]") + meta['tracker_status'][self.tracker]['status_message'] = str(e) + else: + console.print(data) + + async def api_torrent_download(self, meta, passkey, torrent_id): + torrent_url = f"{self.http_upload_url}apidownload" data = { - 'announcekey' : self.announce_key, - 'category' : cat_id - } - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' + 'announcekey': passkey, + 'torrentID': torrent_id } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers) - if not response.text.isnumeric(): - console.print(f'[red]{response.text}') - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - open_desc.close() - def get_name(self, meta): - path = Path(meta['path']) - return path.stem if path.is_file() else path.name + try: + response = await self.session.post(torrent_url, data=data) + response.raise_for_status() + + torrent_filename = f"[{self.tracker}].torrent" + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/{torrent_filename}" + + with open(torrent_path, 'wb') as f: + f.write(response.content) + + console.print(f"[green]Downloaded torrent: {torrent_filename}[/green]") + + except Exception as e: + console.print(f"[bold red]Error downloading torrent from {self.tracker}: {e}[/bold red]") diff --git a/src/trackers/TTG.py b/src/trackers/TTG.py index 491a3bacc..05887d069 100644 --- a/src/trackers/TTG.py +++ b/src/trackers/TTG.py @@ -4,15 +4,12 @@ import asyncio import re import os -from pathlib import Path -import traceback -import json -import distutils.util import cli_ui +import httpx from unidecode import unidecode -from urllib.parse import urlparse, quote +from urllib.parse import urlparse from src.trackers.COMMON import COMMON -from src.exceptions import * +from src.exceptions import * # noqa #F405 from src.console import console @@ -28,11 +25,10 @@ def __init__(self, config): self.passan = str(config['TRACKERS']['TTG'].get('login_answer', '')).strip() self.uid = str(config['TRACKERS']['TTG'].get('user_id', '')).strip() self.passkey = str(config['TRACKERS']['TTG'].get('announce_url', '')).strip().split('/')[-1] - + self.signature = None self.banned_groups = [""] - async def edit_name(self, meta): ttg_name = meta['name'] @@ -48,46 +44,45 @@ async def get_type_id(self, meta): if meta['category'] == "MOVIE": # 51 = DVDRip if meta['resolution'].startswith("720"): - type_id = 52 # 720p + type_id = 52 # 720p if meta['resolution'].startswith("1080"): - type_id = 53 # 1080p/i + type_id = 53 # 1080p/i if meta['is_disc'] == "BDMV": - type_id = 54 # Blu-ray disc - + type_id = 54 # Blu-ray disc + elif meta['category'] == "TV": if meta.get('tv_pack', 0) != 1: # TV Singles if meta['resolution'].startswith("720"): - type_id = 69 # 720p TV EU/US + type_id = 69 # 720p TV EU/US if lang in ('ZH', 'CN', 'CMN'): - type_id = 76 # Chinese + type_id = 76 # Chinese if meta['resolution'].startswith("1080"): - type_id = 70 # 1080 TV EU/US + type_id = 70 # 1080 TV EU/US if lang in ('ZH', 'CN', 'CMN'): - type_id = 75 # Chinese + type_id = 75 # Chinese if lang in ('KR', 'KO'): - type_id = 75 # Korean + type_id = 75 # Korean if lang in ('JA', 'JP'): - type_id = 73 # Japanese + type_id = 73 # Japanese else: # TV Packs - type_id = 87 # EN/US + type_id = 87 # EN/US if lang in ('KR', 'KO'): - type_id = 99 # Korean + type_id = 99 # Korean if lang in ('JA', 'JP'): - type_id = 88 # Japanese + type_id = 88 # Japanese if lang in ('ZH', 'CN', 'CMN'): - type_id = 90 # Chinese - - + type_id = 90 # Chinese + if "documentary" in meta.get("genres", "").lower().replace(' ', '').replace('-', '') or 'documentary' in meta.get("keywords", "").lower().replace(' ', '').replace('-', ''): if meta['resolution'].startswith("720"): - type_id = 62 # 720p + type_id = 62 # 720p if meta['resolution'].startswith("1080"): - type_id = 63 # 1080 + type_id = 63 # 1080 if meta.get('is_disc', '') == 'BDMV': - type_id = 64 # BDMV - + type_id = 64 # BDMV + if "animation" in meta.get("genres", "").lower().replace(' ', '').replace('-', '') or 'animation' in meta.get("keywords", "").lower().replace(' ', '').replace('-', ''): if meta.get('sd', 1) == 0: type_id = 58 @@ -103,70 +98,64 @@ async def get_type_id(self, meta): # 60 = TV Shows return type_id - async def get_anon(self, anon): - if anon == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 'no' - else: - anon = 'yes' - return anon - - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): + async def upload(self, meta, disctype): common = COMMON(config=self.config) await common.edit_torrent(meta, self.tracker, self.source_flag) await self.edit_desc(meta) ttg_name = await self.edit_name(meta) # FORM - # type = category dropdown - # name = name - # descr = description - # anonymity = "yes" / "no" - # nodistr = "yes" / "no" (exclusive?) not required - # imdb_c = tt123456 - # + # type = category dropdown + # name = name + # descr = description + # anonymity = "yes" / "no" + # nodistr = "yes" / "no" (exclusive?) not required + # imdb_c = tt123456 + # # POST > upload/upload - if meta['bdinfo'] != None: + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): + anon = 'no' + else: + anon = 'yes' + + if meta['bdinfo'] is not None: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') else: mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') - ttg_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent" + ttg_desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8').read() + torrent_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" with open(torrent_path, 'rb') as torrentFile: if len(meta['filelist']) == 1: torrentFileName = unidecode(os.path.basename(meta['video']).replace(' ', '.')) else: torrentFileName = unidecode(os.path.basename(meta['path']).replace(' ', '.')) files = { - 'file' : (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent"), - 'nfo' : ("torrent.nfo", mi_dump) + 'file': (f"{torrentFileName}.torrent", torrentFile, "application/x-bittorent"), + 'nfo': ("torrent.nfo", mi_dump) } data = { - 'MAX_FILE_SIZE' : '4000000', - 'team' : '', - 'hr' : 'no', - 'name' : ttg_name, - 'type' : await self.get_type_id(meta), - 'descr' : ttg_desc.rstrip(), - - - 'anonymity' : await self.get_anon(meta['anon']), - 'nodistr' : 'no', - + 'MAX_FILE_SIZE': '4000000', + 'team': '', + 'hr': 'no', + 'name': ttg_name, + 'type': await self.get_type_id(meta), + 'descr': ttg_desc.rstrip(), + + 'anonymity': anon, + 'nodistr': 'no', + } url = "https://totheglory.im/takeupload.php" - if int(meta['imdb_id'].replace('tt', '')) != 0: - data['imdb_c'] = f"tt{meta.get('imdb_id', '').replace('tt', '')}" + if int(meta['imdb_id']) != 0: + data['imdb_c'] = f"tt{meta.get('imdb')}" # Submit if meta['debug']: console.print(url) console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." else: with requests.Session() as session: cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/TTG.pkl") @@ -175,61 +164,74 @@ async def upload(self, meta): up = session.post(url=url, data=data, files=files) torrentFile.close() mi_dump.close() - + if up.url.startswith("https://totheglory.im/details.php?id="): - console.print(f"[green]Uploaded to: [yellow]{up.url}[/yellow][/green]") + meta['tracker_status'][self.tracker]['status_message'] = up.url id = re.search(r"(id=)(\d+)", urlparse(up.url).query).group(2) await self.download_new_torrent(id, torrent_path) else: console.print(data) console.print("\n\n") - console.print(up.text) - raise UploadException(f"Upload to TTG Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') + raise UploadException(f"Upload to TTG Failed: result URL {up.url} ({up.status_code}) was not expected", 'red') # noqa #F405 return - - async def search_existing(self, meta): + async def search_existing(self, meta, disctype): dupes = [] - with requests.Session() as session: - cookiefile = os.path.abspath(f"{meta['base_dir']}/data/cookies/TTG.pkl") - with open(cookiefile, 'rb') as cf: - session.cookies.update(pickle.load(cf)) - - if int(meta['imdb_id'].replace('tt', '')) != 0: - imdb = f"imdb{meta['imdb_id'].replace('tt', '')}" - else: - imdb = "" - if meta.get('is_disc', '') == "BDMV": - res_type = f"{meta['resolution']} Blu-ray" - elif meta.get('is_disc', '') == "DVD": - res_type = "DVD" - else: - res_type = meta['resolution'] - search_url = f"https://totheglory.im/browse.php?search_field= {imdb} {res_type}" - r = session.get(search_url) - await asyncio.sleep(0.5) - soup = BeautifulSoup(r.text, 'html.parser') - find = soup.find_all('a', href=True) - for each in find: - if each['href'].startswith('/t/'): - release = re.search(r"()()?(.*))()?(.*)Logout""") != -1: return True @@ -259,7 +260,7 @@ async def validate_cookies(self, meta, cookiefile): async def login(self, cookiefile): url = "https://totheglory.im/takelogin.php" - data={ + data = { 'username': self.username, 'password': self.password, 'passid': self.passid, @@ -270,11 +271,11 @@ async def login(self, cookiefile): await asyncio.sleep(0.5) if response.url.endswith('2fa.php'): soup = BeautifulSoup(response.text, 'html.parser') - auth_token = soup.find('input', {'name' : 'authenticity_token'}).get('value') + auth_token = soup.find('input', {'name': 'authenticity_token'}).get('value') two_factor_data = { - 'otp' : console.input('[yellow]TTG 2FA Code: '), - 'authenticity_token' : auth_token, - 'uid' : self.uid + 'otp': console.input('[yellow]TTG 2FA Code: '), + 'authenticity_token': auth_token, + 'uid': self.uid } two_factor_url = "https://totheglory.im/take2fa.php" response = session.post(two_factor_url, data=two_factor_data) @@ -290,21 +291,19 @@ async def login(self, cookiefile): console.print(response.url) return - - async def edit_desc(self, meta): - base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() - with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w') as descfile: + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r', encoding='utf-8').read() + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'w', encoding='utf-8') as descfile: from src.bbcode import BBCODE from src.trackers.COMMON import COMMON common = COMMON(config=self.config) - if int(meta.get('imdb_id', '0').replace('tt', '')) != 0: + if int(meta.get('imdb_id')) != 0: ptgen = await common.ptgen(meta) if ptgen.strip() != '': - descfile.write(ptgen) + descfile.write(ptgen) # Add This line for all web-dls - if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '' and meta.get('description', None) == None: + if meta['type'] == 'WEBDL' and meta.get('service_longname', '') != '' and meta.get('description', None) is None: descfile.write(f"[center][b][color=#ff00ff][size=3]{meta['service_longname']}的无损REMUX片源,没有转码/This release is sourced from {meta['service_longname']} and is not transcoded, just remuxed from the direct {meta['service_longname']} stream[/size][/color][/b][/center]") bbcode = BBCODE() if meta.get('discs', []) != []: @@ -327,17 +326,17 @@ async def edit_desc(self, meta): desc = bbcode.convert_spoiler_to_hide(desc) desc = bbcode.convert_comparison_to_centered(desc, 1000) desc = desc.replace('[img]', '[img]') - desc = re.sub("(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) + desc = re.sub(r"(\[img=\d+)]", "[img]", desc, flags=re.IGNORECASE) descfile.write(desc) images = meta['image_list'] - if len(images) > 0: + if len(images) > 0: descfile.write("[center]") for each in range(len(images[:int(meta['screens'])])): web_url = images[each]['web_url'] img_url = images[each]['img_url'] descfile.write(f"[url={web_url}][img]{img_url}[/img][/url]") descfile.write("[/center]") - if self.signature != None: + if self.signature is not None: descfile.write("\n\n") descfile.write(self.signature) descfile.close() @@ -350,4 +349,4 @@ async def download_new_torrent(self, id, torrent_path): tor.write(r.content) else: console.print("[red]There was an issue downloading the new .torrent from TTG") - console.print(r.text) \ No newline at end of file + console.print(r.text) diff --git a/src/trackers/TVC.py b/src/trackers/TVC.py new file mode 100644 index 000000000..1bd60a398 --- /dev/null +++ b/src/trackers/TVC.py @@ -0,0 +1,453 @@ +# -*- coding: utf-8 -*- +# import discord +import asyncio +import requests +import traceback +import cli_ui +import os +import tmdbsimple as tmdb +from src.bbcode import BBCODE +import json +import httpx +from src.trackers.COMMON import COMMON +from src.console import console + + +class TVC(): + """ + Edit for Tracker: + Edit BASE.torrent with announce and source + Check for duplicates + Set type/category IDs + Upload + """ + + def __init__(self, config): + self.config = config + self.tracker = 'TVC' + self.source_flag = 'TVCHAOS' + self.upload_url = 'https://tvchaosuk.com/api/torrents/upload' + self.search_url = 'https://tvchaosuk.com/api/torrents/filter' + self.torrent_url = 'https://tvchaosuk.com/torrents/' + self.signature = "" + self.banned_groups = [] + tmdb.API_KEY = config['DEFAULT']['tmdb_api'] + self.images = { + "imdb_75": 'https://i.imgur.com/Mux5ObG.png', + "tmdb_75": 'https://i.imgur.com/r3QzUbk.png', + "tvdb_75": 'https://i.imgur.com/UWtUme4.png', + "tvmaze_75": 'https://i.imgur.com/ZHEF5nE.png', + "mal_75": 'https://i.imgur.com/PBfdP3M.png' + } + + pass + + async def get_cat_id(self, genres): + # Note sections are based on Genre not type, source, resolution etc.. + self.tv_types = ["comedy", "documentary", "drama", "entertainment", "factual", "foreign", "kids", "movies", "News", "radio", "reality", "soaps", "sci-fi", "sport", "holding bin"] + self.tv_types_ids = ["29", "5", "11", "14", "19", "42", "32", "44", "45", "51", "52", "30", "33", "42", "53"] + + genres = genres.split(', ') + if len(genres) >= 1: + for i in genres: + g = i.lower().replace(',', '') + for s in self.tv_types: + if s.__contains__(g): + return self.tv_types_ids[self.tv_types.index(s)] + + # returning 14 as that is holding bin/misc + return self.tv_types_ids[14] + + async def get_res_id(self, tv_pack, resolution): + if tv_pack: + resolution_id = { + '1080p': 'HD1080p Pack', + '1080i': 'HD1080p Pack', + '720p': 'HD720p Pack', + '576p': 'SD Pack', + '576i': 'SD Pack', + '540p': 'SD Pack', + '540i': 'SD Pack', + '480p': 'SD Pack', + '480i': 'SD Pack' + }.get(resolution, 'SD') + else: + resolution_id = { + '1080p': 'HD1080p', + '1080i': 'HD1080p', + '720p': 'HD720p', + '576p': 'SD', + '576i': 'SD', + '540p': 'SD', + '540': 'SD', + '480p': 'SD', + '480i': 'SD' + }.get(resolution, 'SD') + return resolution_id + + async def upload(self, meta, disctype): + common = COMMON(config=self.config) + await common.edit_torrent(meta, self.tracker, self.source_flag) + await self.get_tmdb_data(meta) + if meta['category'] == 'TV': + cat_id = await self.get_cat_id(meta['genres']) + else: + cat_id = 44 + # type_id = await self.get_type_id(meta['type']) + resolution_id = await self.get_res_id(meta['tv_pack'] if 'tv_pack' in meta else 0, meta['resolution']) + await self.unit3d_edit_desc(meta, self.tracker, self.signature) + + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): + anon = 0 + else: + anon = 1 + + if meta['bdinfo'] is not None: + mi_dump = None + bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() + else: + mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() + bd_dump = None + desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() + open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent", 'rb') + files = {'torrent': open_torrent} + + if meta['type'] == "ENCODE" and (str(meta['path']).lower().__contains__("bluray") or str(meta['path']).lower().__contains__("brrip") or str(meta['path']).lower().__contains__("bdrip")): + type = "BRRip" + else: + type = meta['type'].replace('WEBDL', 'WEB-DL') + + # Naming as per TVC rules. Site has unusual naming conventions. + if meta['category'] == "MOVIE": + tvc_name = f"{meta['title']} ({meta['year']}) [{meta['resolution']} {type} {str(meta['video'][-3:]).upper()}]" + else: + if meta['search_year'] != "": + year = meta['year'] + else: + year = "" + if meta.get('no_season', False) is True: + season = '' + if meta.get('no_year', False) is True: + year = '' + + if meta['category'] == "TV": + if meta['tv_pack']: + # seasons called series here. + tvc_name = f"{meta['title']} ({meta['year'] if 'season_air_first_date' and len(meta['season_air_first_date']) >= 4 else meta['season_air_first_date'][:4]}) Series {meta['season_int']} [{meta['resolution']} {type} {str(meta['video'][-3:]).upper()}]".replace(" ", " ").replace(' () ', ' ') + else: + if 'episode_airdate' in meta: + tvc_name = f"{meta['title']} ({year}) {meta['season']}{meta['episode']} ({meta['episode_airdate']}) [{meta['resolution']} {type} {str(meta['video'][-3:]).upper()}]".replace(" ", " ").replace(' () ', ' ') + else: + tvc_name = f"{meta['title']} ({year}) {meta['season']}{meta['episode']} [{meta['resolution']} {type} {str(meta['video'][-3:]).upper()}]".replace(" ", " ").replace(' () ', ' ') + + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MediaInfo.json", 'r', encoding='utf-8') as f: + mi = json.load(f) + + if not meta['is_disc']: + self.get_subs_info(meta, mi) + + if meta['video_codec'] == 'HEVC': + tvc_name = tvc_name.replace(']', ' HEVC]') + + if 'eng_subs' in meta and meta['eng_subs']: + tvc_name = tvc_name.replace(']', ' SUBS]') + if 'sdh_subs' in meta and meta['eng_subs']: + if 'eng_subs' in meta and meta['eng_subs']: + tvc_name = tvc_name.replace(' SUBS]', ' (ENG + SDH SUBS)]') + else: + tvc_name = tvc_name.replace(']', ' (SDH SUBS)]') + + if 'origin_country_code' in meta: + if "IE" in meta['origin_country_code']: + tvc_name += " [IRL]" + elif "AU" in meta['origin_country_code']: + tvc_name += " [AUS]" + elif "NZ" in meta['origin_country_code']: + tvc_name += " [NZ]" + elif "CA" in meta['origin_country_code']: + tvc_name += " [CA]" + + if meta.get('unattended', False) is False: + upload_to_tvc = cli_ui.ask_yes_no(f"Upload to {self.tracker} with the name {tvc_name}?", default=False) + + if not upload_to_tvc: + tvc_name = cli_ui.ask_string("Please enter New Name:") + upload_to_tvc = cli_ui.ask_yes_no(f"Upload to {self.tracker} with the name {tvc_name}?", default=False) + + data = { + 'name': tvc_name, + # newline does not seem to work on this site for some reason. if you edit and save it again they will but not if pushed by api + 'description': desc.replace('\n', '
').replace('\r', '
'), + 'mediainfo': mi_dump, + 'bdinfo': bd_dump, + 'category_id': cat_id, + 'type': resolution_id, + # 'resolution_id': resolution_id, + 'tmdb': meta['tmdb'], + 'imdb': meta['imdb'], + 'tvdb': meta['tvdb_id'], + 'mal': meta['mal_id'], + 'igdb': 0, + 'anonymous': anon, + 'stream': meta['stream'], + 'sd': meta['sd'], + 'keywords': meta['keywords'], + 'personal_release': int(meta.get('personalrelease', False)), + 'internal': 0, + 'featured': 0, + 'free': 0, + 'doubleup': 0, + 'sticky': 0, + } + + if meta.get('category') == "TV": + data['season_number'] = meta.get('season_int', '0') + data['episode_number'] = meta.get('episode_int', '0') + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:53.0) Gecko/20100101 Firefox/53.0' + } + params = { + 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip() + } + if 'upload_to_tvc' in locals() and upload_to_tvc is False: + return + + if meta['debug'] is False: + response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) + try: + # some reason this does not return json instead it returns something like below. + # b'application/x-bittorrent\n{"success":true,"data":"https:\\/\\/tvchaosuk.com\\/torrent\\/download\\/164633.REDACTED","message":"Torrent uploaded successfully."}' + # so you need to convert text to json. + json_data = json.loads(response.text.strip('application/x-bittorrent\n')) + meta['tracker_status'][self.tracker]['status_message'] = json_data + # adding torrent link to comment of torrent file + t_id = json_data['data'].split(".")[1].split("/")[3] + meta['tracker_status'][self.tracker]['torrent_id'] = t_id + await common.add_tracker_torrent(meta, self.tracker, self.source_flag, + self.config['TRACKERS'][self.tracker].get('announce_url'), + "https://tvchaosuk.com/torrents/" + t_id) + + except Exception: + console.print(traceback.print_exc()) + console.print("[yellow]It may have uploaded, go check") + console.print(response.text.strip('application/x-bittorrent\n')) + return + else: + console.print("[cyan]Request Data:") + console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = "Debug mode enabled, not uploading." + open_torrent.close() + + # why the fuck is this even a thing..... + async def get_tmdb_data(self, meta): + import tmdbsimple as tmdb + if meta['category'] == "MOVIE": + movie = tmdb.Movies(meta['tmdb']) + response = movie.info() + else: + tv = tmdb.TV(meta['tmdb']) + response = tv.info() + + # TVC stuff + if meta['category'] == "TV": + if hasattr(tv, 'release_dates'): + meta['release_dates'] = tv.release_dates() + + if hasattr(tv, 'networks') and len(tv.networks) != 0 and 'name' in tv.networks[0]: + meta['networks'] = tv.networks[0]['name'] + + try: + if 'tv_pack' in meta and not meta['tv_pack']: + episode_info = tmdb.TV_Episodes(meta['tmdb'], meta['season_int'], meta['episode_int']).info() + + meta['episode_airdate'] = episode_info['air_date'] + meta['episode_name'] = episode_info['name'] + meta['episode_overview'] = episode_info['overview'] + if 'tv_pack' in meta and meta['tv_pack']: + season_info = tmdb.TV_Seasons(meta['tmdb'], meta['season_int']).info() + meta['season_air_first_date'] = season_info['air_date'] + + if hasattr(tv, 'first_air_date'): + meta['first_air_date'] = tv.first_air_date + except Exception: + console.print(traceback.print_exc()) + console.print(f"Unable to get episode information, Make sure episode {meta['season']}{meta['episode']} exists in TMDB. \nhttps://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}/season/{meta['season_int']}") + meta['season_air_first_date'] = str({meta["year"]}) + "-N/A-N/A" + meta['first_air_date'] = str({meta["year"]}) + "-N/A-N/A" + + meta['origin_country_code'] = [] + if 'origin_country' in response: + if isinstance(response['origin_country'], list): + for i in response['origin_country']: + meta['origin_country_code'].append(i) + else: + meta['origin_country_code'].append(response['origin_country']) + print(type(response['origin_country'])) + + elif len(response['production_countries']): + for i in response['production_countries']: + if 'iso_3166_1' in i: + meta['origin_country_code'].append(i['iso_3166_1']) + elif len(response['production_companies']): + meta['origin_country_code'].append(response['production_companies'][0]['origin_country']) + + async def search_existing(self, meta, disctype): + # Search on TVCUK has been DISABLED due to issues + # leaving code here for future use when it is re-enabled + console.print("[red]Cannot search for dupes as search api is not working...") + console.print("[red]Please make sure you are not uploading duplicates.") + # https://tvchaosuk.com/api/torrents/filter?api_token=&tmdb=138108 + + dupes = [] + + # UHD, Discs, remux and non-1080p HEVC are not allowed on TVC. + if meta['resolution'] == '2160p' or (meta['is_disc'] or "REMUX" in meta['type']) or (meta['video_codec'] == 'HEVC' and meta['resolution'] != '1080p'): + console.print("[bold red]No UHD, Discs, Remuxes or non-1080p HEVC allowed at TVC[/bold red]") + meta['skipping'] = "TVC" + return [] + + params = { + 'api_token': self.config['TRACKERS'][self.tracker]['api_key'].strip(), + 'tmdb': meta['tmdb'], + 'name': "" + } + + try: + async with httpx.AsyncClient(timeout=5.0) as client: + response = await client.get(url=self.search_url, params=params) + if response.status_code == 200: + data = response.json() + # 404 catch when their api is down + if data['data'] != '404': + for each in data['data']: + print(each[0]['attributes']['name']) + result = each[0]['attributes']['name'] + dupes.append(result) + else: + console.print("Search API is down, please check manually") + else: + console.print(f"[bold red]Failed to search torrents. HTTP Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out after 5 seconds") + except httpx.RequestError as e: + console.print(f"[bold red]Unable to search for existing torrents: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + await asyncio.sleep(5) + + return dupes + + async def unit3d_edit_desc(self, meta, tracker, signature, comparison=False): + base = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/DESCRIPTION.txt", 'r').read() + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{tracker}]DESCRIPTION.txt", 'w') as descfile: + bbcode = BBCODE() + if meta.get('discs', []) != []: + discs = meta['discs'] + if discs[0]['type'] == "DVD": + descfile.write(f"[spoiler=VOB MediaInfo][code]{discs[0]['vob_mi']}[/code][/spoiler]\n") + descfile.write("\n") + if len(discs) >= 2: + for each in discs[1:]: + if each['type'] == "BDMV": + descfile.write(f"[spoiler={each.get('name', 'BDINFO')}][code]{each['summary']}[/code][/spoiler]\n") + descfile.write("\n") + if each['type'] == "DVD": + descfile.write(f"{each['name']}:\n") + descfile.write(f"[spoiler={os.path.basename(each['vob'])}][code][{each['vob_mi']}[/code][/spoiler] [spoiler={os.path.basename(each['ifo'])}][code][{each['ifo_mi']}[/code][/spoiler]\n") + descfile.write("\n") + desc = "" + + # release info + rd_info = "" + # getting movie release info + if meta['category'] != "TV" and 'release_dates' in meta: + for cc in meta['release_dates']['results']: + for rd in cc['release_dates']: + if rd['type'] == 6: + channel = str(rd['note']) if str(rd['note']) != "" else "N/A Channel" + rd_info += "[color=orange][size=15]" + cc['iso_3166_1'] + " TV Release info [/size][/color]" + "\n" + str(rd['release_date'])[:10] + " on " + channel + "\n" + # movie release info adding + if rd_info != "": + desc += "[color=green][size=25]Release Info[/size][/color]" + "\n\n" + desc += rd_info + "\n\n" + # getting season release info. need to fix so it gets season info instead of first episode info. + elif meta['category'] == "TV" and meta['tv_pack'] == 1 and 'first_air_date' in meta: + channel = meta['networks'] if 'networks' in meta and meta['networks'] != "" else "N/A" + desc += "[color=green][size=25]Release Info[/size][/color]" + "\n\n" + desc += f"[color=orange][size=15]First episode of this season aired {meta['season_air_first_date']} on channel {channel}[/size][/color]" + "\n\n" + elif meta['category'] == "TV" and meta['tv_pack'] != 1 and 'episode_airdate' in meta: + channel = meta['networks'] if 'networks' in meta and meta['networks'] != "" else "N/A" + desc += "[color=green][size=25]Release Info[/size][/color]" + "\n\n" + desc += f"[color=orange][size=15]Episode aired on channel {channel} on {meta['episode_airdate']}[/size][/color]" + "\n\n" + else: + desc += "[color=green][size=25]Release Info[/size][/color]" + "\n\n" + desc += "[color=orange][size=15]TMDB has No TV release info for this[/size][/color]" + "\n\n" + + if meta['category'] == 'TV' and meta['tv_pack'] != 1 and 'episode_overview' in meta: + desc += "\n\n" + "[color=green][size=25]PLOT[/size][/color]\n" + "Episode Name: " + str(meta['episode_name']) + "\n" + str(meta['episode_overview'] + "\n\n") + else: + desc += "[color=green][size=25]PLOT[/size][/color]" + "\n" + str(meta['overview'] + "\n\n") + # Max two screenshots as per rules + if len(base) > 2 and meta['description'] != "PTP": + desc += "[color=green][size=25]Notes/Extra Info[/size][/color]" + " \n \n" + str(base) + " \n \n " + desc += self.get_links(meta, "[color=green][size=25]", "[/size][/COLOR]") + desc = bbcode.convert_pre_to_code(desc) + desc = bbcode.convert_hide_to_spoiler(desc) + if comparison is False: + desc = bbcode.convert_comparison_to_collapse(desc, 1000) + descfile.write(desc) + images = meta['image_list'] + # only adding 2 screens as that is mentioned in rules. + if len(images) > 0 and int(meta['screens']) >= 2: + descfile.write("[color=green][size=25]Screenshots[/size][/color]\n\n[center]") + for each in range(len(images[:2])): + web_url = images[each]['web_url'] + img_url = images[each]['img_url'] + descfile.write(f"[url={web_url}][img=350]{img_url}[/img][/url]") + descfile.write("[/center]") + + if signature is not None: + descfile.write(signature) + descfile.close() + return + + def get_links(self, movie, subheading, heading_end): + description = "" + description += "\n\n" + subheading + "Links" + heading_end + "\n" + if movie['imdb_id'] != "0": + description += f"[URL=https://www.imdb.com/title/tt{movie['imdb']}][img]{self.images['imdb_75']}[/img][/URL]" + if movie['tmdb'] != "0": + description += f" [URL=https://www.themoviedb.org/{str(movie['category'].lower())}/{str(movie['tmdb'])}][img]{self.images['tmdb_75']}[/img][/URL]" + if movie['tvdb_id'] != 0: + description += f" [URL=https://www.thetvdb.com/?id={str(movie['tvdb_id'])}&tab=series][img]{self.images['tvdb_75']}[/img][/URL]" + if movie['tvmaze_id'] != 0: + description += f" [URL=https://www.tvmaze.com/shows/{str(movie['tvmaze_id'])}][img]{self.images['tvmaze_75']}[/img][/URL]" + if movie['mal_id'] != 0: + description += f" [URL=https://myanimelist.net/anime/{str(movie['mal_id'])}][img]{self.images['mal_75']}[/img][/URL]" + return description + " \n \n " + + # get subs function + # used in naming conventions + def get_subs_info(self, meta, mi): + subs = "" + subs_num = 0 + for s in mi.get("media").get("track"): + if s["@type"] == "Text": + subs_num = subs_num + 1 + if subs_num >= 1: + meta['has_subs'] = 1 + else: + meta['has_subs'] = 0 + for s in mi.get("media").get("track"): + if s["@type"] == "Text": + if "Language" in s: + if not subs_num <= 0: + subs = subs + s["Language"] + ", " + # checking if it has english subs as for data scene. + if str(s["Language"]).lower().__contains__("en"): + meta['eng_subs'] = 1 + if str(s).lower().__contains__("sdh"): + meta['sdh_subs'] = 1 + + return + # get subs function^^^^ diff --git a/src/trackers/UHD.py b/src/trackers/UHD.py new file mode 100644 index 000000000..ff876c717 --- /dev/null +++ b/src/trackers/UHD.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class UHD(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='UHD') + self.config = config + self.common = COMMON(config) + self.tracker = 'UHD' + self.source_flag = 'UHD' + self.base_url = 'https://uhdshare.com' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass diff --git a/src/trackers/ULCX.py b/src/trackers/ULCX.py new file mode 100644 index 000000000..ceabf88c0 --- /dev/null +++ b/src/trackers/ULCX.py @@ -0,0 +1,103 @@ +# -*- coding: utf-8 -*- +# import discord +import cli_ui +from difflib import SequenceMatcher +from src.console import console +from src.languages import process_desc_language, has_english_language +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class ULCX(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='ULCX') + self.config = config + self.common = COMMON(config) + self.tracker = 'ULCX' + self.source_flag = 'ULCX' + self.base_url = 'https://upload.cx' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [ + '4K4U', 'AROMA', 'd3g', ['EDGE2020', 'Encodes'], 'EMBER', 'FGT', 'FnP', 'FRDS', 'Grym', 'Hi10', 'iAHD', 'INFINITY', + 'ION10', 'iVy', 'Judas', 'LAMA', 'MeGusta', 'NAHOM', 'Niblets', 'nikt0', ['NuBz', 'Encodes'], 'OFT', 'QxR', + ['Ralphy', 'Encodes'], 'RARBG', 'Sicario', 'SM737', 'SPDVD', 'SWTYBLZ', 'TAoE', 'TGx', 'Tigole', 'TSP', + 'TSPxL', 'VXT', 'Vyndros', 'Will1869', 'x0r', 'YIFY', 'Alcaide_Kira', 'PHOCiS' + ] + pass + + async def get_additional_checks(self, meta): + should_continue = True + if 'concert' in meta['keywords']: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print(f'[bold red]Concerts not allowed at {self.tracker}.[/bold red]') + if cli_ui.ask_yes_no("Do you want to upload anyway?", default=False): + pass + else: + meta['skipping'] = {self.tracker} + return False + else: + meta['skipping'] = {self.tracker} + return False + if meta['video_codec'] == "HEVC" and meta['resolution'] != "2160p" and 'animation' not in meta['keywords'] and meta.get('anime', False) is not True: + if not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False)): + console.print(f'[bold red]This content might not fit HEVC rules for {self.tracker}.[/bold red]') + if cli_ui.ask_yes_no("Do you want to upload anyway?", default=False): + pass + else: + meta['skipping'] = {self.tracker} + return False + else: + meta['skipping'] = {self.tracker} + return False + if meta['type'] == "ENCODE" and meta['resolution'] not in ['8640p', '4320p', '2160p', '1440p', '1080p', '1080i', '720p']: + if not meta['unattended']: + console.print(f'[bold red]Encodes must be at least 720p resolution for {self.tracker}.[/bold red]') + meta['skipping'] = {self.tracker} + return False + if meta['bloated'] is True: + console.print(f"[bold red]Non-English dub not allowed at {self.tracker}[/bold red]") + meta['skipping'] = {self.tracker} + return False + + if not meta['is_disc'] == "BDMV": + if not meta.get('language_checked', False): + await process_desc_language(meta, desc=None, tracker=self.tracker) + if not await has_english_language(meta.get('audio_languages')) and not await has_english_language(meta.get('subtitle_languages')): + if not meta['unattended']: + console.print(f'[bold red]{self.tracker} requires at least one English audio or subtitle track.') + meta['skipping'] = {self.tracker} + return False + + return should_continue + + async def get_additional_data(self, meta): + data = { + 'mod_queue_opt_in': await self.get_flag(meta, 'modq'), + } + + return data + + async def get_name(self, meta): + ulcx_name = meta['name'] + imdb_name = meta.get('imdb_info', {}).get('title', "") + imdb_year = str(meta.get('imdb_info', {}).get('year', "")) + year = str(meta.get('year', "")) + aka = meta.get('aka', "") + if imdb_name and imdb_name != "": + difference = SequenceMatcher(None, imdb_name, aka).ratio() + if difference >= 0.7 or not aka or aka in imdb_name: + if meta['aka'] != "": + ulcx_name = ulcx_name.replace(f"{meta['aka']} ", "", 1) + ulcx_name = ulcx_name.replace(f"{meta['title']}", imdb_name, 1) + if "Hybrid" in ulcx_name: + ulcx_name = ulcx_name.replace("Hybrid ", "", 1) + if not meta.get('category') == "TV" and imdb_year and imdb_year != "" and year and year != "" and imdb_year != year: + ulcx_name = ulcx_name.replace(f"{year}", imdb_year, 1) + if meta.get('mal_id', 0) != 0 and meta.get('aka', "") != "": + ulcx_name = ulcx_name.replace(f"{meta['aka']} ", "", 1) + + return {'name': ulcx_name} diff --git a/src/trackers/UNIT3D.py b/src/trackers/UNIT3D.py new file mode 100644 index 000000000..05eaed508 --- /dev/null +++ b/src/trackers/UNIT3D.py @@ -0,0 +1,406 @@ +# -*- coding: utf-8 -*- +# import discord +import aiofiles +import asyncio +import glob +import httpx +import os +import platform +import re +from src.console import console +from src.trackers.COMMON import COMMON + + +class UNIT3D: + def __init__(self, config, tracker_name): + self.config = config + self.tracker = tracker_name + self.common = COMMON(config) + tracker_config = self.config['TRACKERS'].get(self.tracker, {}) + self.announce_url = tracker_config.get('announce_url', '') + self.api_key = tracker_config.get('api_key', '') + self.ua_name = f'Upload Assistant {self.common.get_version()}'.strip() + self.signature = f'\n[center][url=https://github.com/Audionut/Upload-Assistant]Created by {self.ua_name}[/url][/center]' + pass + + async def get_additional_checks(self, meta): + should_continue = True + return should_continue + + async def search_existing(self, meta, disctype): + if not self.api_key: + console.print(f'[bold red]{self.tracker}: Missing API key in config file. Skipping upload...[/bold red]') + meta['skipping'] = f'{self.tracker}' + return + + should_continue = await self.get_additional_checks(meta) + if not should_continue: + meta['skipping'] = f'{self.tracker}' + return + + dupes = [] + params = { + 'api_token': self.api_key, + 'tmdbId': meta['tmdb'], + 'categories[]': (await self.get_category_id(meta))['category_id'], + 'types[]': (await self.get_type_id(meta))['type_id'], + 'resolutions[]': (await self.get_resolution_id(meta))['resolution_id'], + 'name': '' + } + if meta['category'] == 'TV': + params['name'] = params['name'] + f" {meta.get('season', '')}" + if meta.get('edition', '') != '': + params['name'] = params['name'] + f" {meta['edition']}" + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url=self.search_url, params=params) + if response.status_code == 200: + data = response.json() + for each in data['data']: + attributes = each.get('attributes', {}) + if not meta['is_disc']: + result = { + 'name': attributes['name'], + 'size': attributes['size'], + 'files': [file['name'] for file in attributes.get('files', []) if isinstance(file, dict) and 'name' in file], + 'file_count': len(attributes.get('files', [])) if isinstance(attributes.get('files'), list) else 0, + 'trumpable': attributes.get('trumpable', False), + 'link': attributes.get('details_link', None) + } + else: + result = { + 'name': attributes['name'], + 'size': attributes['size'], + 'trumpable': attributes.get('trumpable', False), + 'link': attributes.get('details_link', None) + } + dupes.append(result) + else: + console.print(f'[bold red]Failed to search torrents. HTTP Status: {response.status_code}') + except httpx.TimeoutException: + console.print('[bold red]Request timed out after 10 seconds') + except httpx.RequestError as e: + console.print(f'[bold red]Unable to search for existing torrents: {e}') + except Exception as e: + console.print(f'[bold red]Unexpected error: {e}') + await asyncio.sleep(5) + + return dupes + + async def get_name(self, meta): + return {'name': meta['name']} + + async def get_description(self, meta): + await self.common.unit3d_edit_desc(meta, self.tracker, self.signature) + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r', encoding='utf-8') as f: + desc = await f.read() + return {'description': desc} + + async def get_mediainfo(self, meta): + if meta['bdinfo'] is not None: + mediainfo = None + else: + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8') as f: + mediainfo = await f.read() + return {'mediainfo': mediainfo} + + async def get_bdinfo(self, meta): + if meta['bdinfo'] is not None: + async with aiofiles.open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8') as f: + bdinfo = await f.read() + else: + bdinfo = None + return {'bdinfo': bdinfo} + + async def get_category_id(self, meta, category=None, reverse=False, mapping_only=False): + category_id = { + 'MOVIE': '1', + 'TV': '2', + } + if mapping_only: + return category_id + elif reverse: + return {v: k for k, v in category_id.items()} + elif category is not None: + return {'category_id': category_id.get(category, '0')} + else: + meta_category = meta.get('category', '') + resolved_id = category_id.get(meta_category, '0') + return {'category_id': resolved_id} + + async def get_type_id(self, meta, type=None, reverse=False, mapping_only=False): + type_id = { + 'DISC': '1', + 'REMUX': '2', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6', + 'ENCODE': '3', + 'DVDRIP': '3', + } + if mapping_only: + return type_id + elif reverse: + return {v: k for k, v in type_id.items()} + elif type is not None: + return {'type_id': type_id.get(type, '0')} + else: + meta_type = meta.get('type', '') + resolved_id = type_id.get(meta_type, '0') + return {'type_id': resolved_id} + + async def get_resolution_id(self, meta, resolution=None, reverse=False, mapping_only=False): + resolution_id = { + '8640p': '10', + '4320p': '1', + '2160p': '2', + '1440p': '3', + '1080p': '3', + '1080i': '4', + '720p': '5', + '576p': '6', + '576i': '7', + '480p': '8', + '480i': '9' + } + if mapping_only: + return resolution_id + elif reverse: + return {v: k for k, v in resolution_id.items()} + elif resolution is not None: + return {'resolution_id': resolution_id.get(resolution, '10')} + else: + meta_resolution = meta.get('resolution', '') + resolved_id = resolution_id.get(meta_resolution, '10') + return {'resolution_id': resolved_id} + + async def get_anonymous(self, meta): + if meta['anon'] == 0 and not self.config['TRACKERS'][self.tracker].get('anon', False): + anonymous = 0 + else: + anonymous = 1 + return {'anonymous': anonymous} + + async def get_additional_data(self, meta): + # Used to add additional data if needed + ''' + data = { + 'modq': await self.get_flag(meta, 'modq'), + 'draft': await self.get_flag(meta, 'draft'), + } + ''' + data = {} + + return data + + async def get_flag(self, meta, flag_name): + config_flag = self.config['TRACKERS'][self.tracker].get(flag_name) + if meta.get(flag_name, False): + return 1 + else: + if config_flag is not None: + return 1 if config_flag else 0 + else: + return 0 + + async def get_distributor_id(self, meta): + distributor_id = await self.common.unit3d_distributor_ids(meta.get('distributor')) + if distributor_id != 0: + return {'distributor_id': distributor_id} + + return {} + + async def get_region_id(self, meta): + region_id = await self.common.unit3d_region_ids(meta.get('region')) + if region_id != 0: + return {'region_id': region_id} + + return {} + + async def get_tmdb(self, meta): + return {'tmdb': meta['tmdb']} + + async def get_imdb(self, meta): + return {'imdb': meta['imdb']} + + async def get_tvdb(self, meta): + tvdb = meta.get('tvdb_id', 0) if meta['category'] == 'TV' else 0 + return {'tvdb': tvdb} + + async def get_mal(self, meta): + return {'mal': meta['mal_id']} + + async def get_igdb(self, meta): + return {'igdb': 0} + + async def get_stream(self, meta): + return {'stream': meta['stream']} + + async def get_sd(self, meta): + return {'sd': meta['sd']} + + async def get_keywords(self, meta): + return {'keywords': meta.get('keywords', '')} + + async def get_personal_release(self, meta): + personal_release = int(meta.get('personalrelease', False)) + return {'personal_release': personal_release} + + async def get_internal(self, meta): + internal = 0 + if self.config['TRACKERS'][self.tracker].get('internal', False) is True: + if meta['tag'] != '' and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): + internal = 1 + + return {'internal': internal} + + async def get_season_number(self, meta): + data = {} + if meta.get('category') == 'TV': + data = {'season_number': meta.get('season_int', '0')} + + return data + + async def get_episode_number(self, meta): + data = {} + if meta.get('category') == 'TV': + data = {'episode_number': meta.get('episode_int', '0')} + + return data + + async def get_featured(self, meta): + return {'featured': 0} + + async def get_free(self, meta): + free = 0 + if meta.get('freeleech', 0) != 0: + free = meta.get('freeleech', 0) + + return {'free': free} + + async def get_doubleup(self, meta): + return {'doubleup': 0} + + async def get_sticky(self, meta): + return {'sticky': 0} + + async def get_data(self, meta): + results = await asyncio.gather( + self.get_name(meta), + self.get_description(meta), + self.get_mediainfo(meta), + self.get_bdinfo(meta), + self.get_category_id(meta), + self.get_type_id(meta), + self.get_resolution_id(meta), + self.get_tmdb(meta), + self.get_imdb(meta), + self.get_tvdb(meta), + self.get_mal(meta), + self.get_igdb(meta), + self.get_anonymous(meta), + self.get_stream(meta), + self.get_sd(meta), + self.get_keywords(meta), + self.get_personal_release(meta), + self.get_internal(meta), + self.get_season_number(meta), + self.get_episode_number(meta), + self.get_featured(meta), + self.get_free(meta), + self.get_doubleup(meta), + self.get_sticky(meta), + self.get_additional_data(meta), + self.get_region_id(meta), + self.get_distributor_id(meta), + ) + + merged = {} + for r in results: + if not isinstance(r, dict): + raise TypeError(f'Expected dict, got {type(r)}: {r}') + merged.update(r) + + return merged + + async def get_additional_files(self, meta): + files = {} + base_dir = meta['base_dir'] + uuid = meta['uuid'] + specified_dir_path = os.path.join(base_dir, 'tmp', uuid, '*.nfo') + nfo_files = glob.glob(specified_dir_path) + + if nfo_files: + async with aiofiles.open(nfo_files[0], 'rb') as f: + nfo_bytes = await f.read() + files['nfo'] = ("nfo_file.nfo", nfo_bytes, "text/plain") + + return files + + async def upload(self, meta, disctype): + data = await self.get_data(meta) + await self.common.edit_torrent(meta, self.tracker, self.source_flag) + + torrent_file_path = f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}].torrent" + async with aiofiles.open(torrent_file_path, 'rb') as f: + torrent_bytes = await f.read() + files = {'torrent': ('torrent.torrent', torrent_bytes, 'application/x-bittorrent')} + files.update(await self.get_additional_files(meta)) + headers = {'User-Agent': f'{self.ua_name} ({platform.system()} {platform.release()})'} + params = {'api_token': self.api_key} + + if meta['debug'] is False: + response_data = {} + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) + response_data = response.json() + meta['tracker_status'][self.tracker]['status_message'] = await self.process_response_data(response_data) + torrent_id = await self.get_torrent_id(response_data) + + meta['tracker_status'][self.tracker]['torrent_id'] = torrent_id + await self.common.add_tracker_torrent( + meta, + self.tracker, + self.source_flag, + self.announce_url, + self.torrent_url + torrent_id, + headers=headers, + params=params, + downurl=response_data['data'] + ) + except httpx.TimeoutException: + meta['tracker_status'][self.tracker]['status_message'] = 'data error: Request timed out after 10 seconds' + except httpx.RequestError as e: + meta['tracker_status'][self.tracker]['status_message'] = f'data error: Unable to upload. Error: {e}.\nResponse: {response_data}' + except Exception as e: + meta['tracker_status'][self.tracker]['status_message'] = f'data error: It may have uploaded, go check. Error: {e}.\nResponse: {response_data}' + return + else: + console.print(f'[cyan]{self.tracker} Request Data:') + console.print(data) + meta['tracker_status'][self.tracker]['status_message'] = f'Debug mode enabled, not uploading: {self.tracker}.' + + async def get_torrent_id(self, response_data): + """Matches /12345.abcde and returns 12345""" + torrent_id = '' + try: + match = re.search(r'/(\d+)\.', response_data['data']) + if match: + torrent_id = match.group(1) + except (IndexError, KeyError): + print('Could not parse torrent_id from response data.') + return torrent_id + + async def process_response_data(self, response_data): + """Returns only the success message from the response data if the upload is successful; otherwise, returns the complete response data.""" + status_message = '' + try: + if response_data['success'] is True: + status_message = response_data['message'] + else: + status_message = response_data + except Exception: + pass + + return status_message diff --git a/src/trackers/UNIT3D_TEMPLATE.py b/src/trackers/UNIT3D_TEMPLATE.py index 405e2c9f1..2f1251a27 100644 --- a/src/trackers/UNIT3D_TEMPLATE.py +++ b/src/trackers/UNIT3D_TEMPLATE.py @@ -1,183 +1,83 @@ # -*- coding: utf-8 -*- # import discord -import asyncio -import requests -import distutils.util -import os -import platform - from src.trackers.COMMON import COMMON -from src.console import console - - -class UNIT3D_TEMPLATE(): - """ - Edit for Tracker: - Edit BASE.torrent with announce and source - Check for duplicates - Set type/category IDs - Upload - """ - - ############################################################### - ######## EDIT ME ######## - ############################################################### +from src.trackers.UNIT3D import UNIT3D - # ALSO EDIT CLASS NAME ABOVE +class UNIT3D_TEMPLATE(UNIT3D): # EDIT 'UNIT3D_TEMPLATE' AS ABBREVIATED TRACKER NAME def __init__(self, config): + super().__init__(config, tracker_name='UNIT3D_TEMPLATE') # EDIT 'UNIT3D_TEMPLATE' AS ABBREVIATED TRACKER NAME self.config = config - self.tracker = 'Abbreviated' + self.common = COMMON(config) + self.tracker = 'Abbreviated Tracker Name' self.source_flag = 'Source flag for .torrent' - self.upload_url = 'https://domain.tld/api/torrents/upload' - self.search_url = 'https://domain.tld/api/torrents/filter' - self.signature = None + self.base_url = 'https://domain.tld' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.requests_url = f'{self.base_url}/api/requests/filter' # If the site supports requests via API, otherwise remove this line + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' self.banned_groups = [""] pass - - async def get_cat_id(self, category_name): + + # The section below can be deleted if no changes are needed, as everything else is handled in UNIT3D.py + # If advanced changes are required, copy the necessary functions from UNIT3D.py here + # For example, if you need to modify the description, copy and paste the 'get_description' function and adjust it accordingly + + # If default UNIT3D categories, remove this function + async def get_category_id(self, meta): category_id = { - 'MOVIE': '1', - 'TV': '2', - }.get(category_name, '0') - return category_id + 'MOVIE': '1', + 'TV': '2', + }.get(meta['category'], '0') + return {'category_id': category_id} - async def get_type_id(self, type): + # If default UNIT3D types, remove this function + async def get_type_id(self, meta): type_id = { - 'DISC': '1', + 'DISC': '1', 'REMUX': '2', - 'WEBDL': '4', - 'WEBRIP': '5', + 'WEBDL': '4', + 'WEBRIP': '5', 'HDTV': '6', 'ENCODE': '3' - }.get(type, '0') - return type_id + }.get(meta['type'], '0') + return {'type_id': type_id} - async def get_res_id(self, resolution): + # If default UNIT3D resolutions, remove this function + async def get_resolution_id(self, meta): resolution_id = { - '8640p':'10', - '4320p': '1', - '2160p': '2', - '1440p' : '3', + '8640p': '10', + '4320p': '1', + '2160p': '2', + '1440p': '3', '1080p': '3', - '1080i':'4', - '720p': '5', - '576p': '6', + '1080i': '4', + '720p': '5', + '576p': '6', '576i': '7', - '480p': '8', + '480p': '8', '480i': '9' - }.get(resolution, '10') - return resolution_id - - ############################################################### - ###### STOP HERE UNLESS EXTRA MODIFICATION IS NEEDED ###### - ############################################################### - - async def upload(self, meta): - common = COMMON(config=self.config) - await common.edit_torrent(meta, self.tracker, self.source_flag) - cat_id = await self.get_cat_id(meta['category']) - type_id = await self.get_type_id(meta['type']) - resolution_id = await self.get_res_id(meta['resolution']) - await common.unit3d_edit_desc(meta, self.tracker, self.signature) - region_id = await common.unit3d_region_ids(meta.get('region')) - distributor_id = await common.unit3d_distributor_ids(meta.get('distributor')) - if meta['anon'] == 0 and bool(distutils.util.strtobool(str(self.config['TRACKERS'][self.tracker].get('anon', "False")))) == False: - anon = 0 - else: - anon = 1 - - if meta['bdinfo'] != None: - mi_dump = None - bd_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/BD_SUMMARY_00.txt", 'r', encoding='utf-8').read() - else: - mi_dump = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/MEDIAINFO.txt", 'r', encoding='utf-8').read() - bd_dump = None - desc = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]DESCRIPTION.txt", 'r').read() - open_torrent = open(f"{meta['base_dir']}/tmp/{meta['uuid']}/[{self.tracker}]{meta['clean_name']}.torrent", 'rb') - files = {'torrent': open_torrent} + }.get(meta['resolution'], '10') + return {'resolution_id': resolution_id} + + # If there are tracker specific checks to be done before upload, add them here + # Is it a movie only tracker? Are concerts banned? Etc. + # If no checks are necessary, remove this function + async def get_additional_checks(self, meta): + should_continue = True + return should_continue + + # If the tracker has modq in the api, otherwise remove this function + # If no additional data is required, remove this function + async def get_additional_data(self, meta): data = { - 'name' : meta['name'], - 'description' : desc, - 'mediainfo' : mi_dump, - 'bdinfo' : bd_dump, - 'category_id' : cat_id, - 'type_id' : type_id, - 'resolution_id' : resolution_id, - 'tmdb' : meta['tmdb'], - 'imdb' : meta['imdb_id'].replace('tt', ''), - 'tvdb' : meta['tvdb_id'], - 'mal' : meta['mal_id'], - 'igdb' : 0, - 'anonymous' : anon, - 'stream' : meta['stream'], - 'sd' : meta['sd'], - 'keywords' : meta['keywords'], - 'personal_release' : int(meta.get('personalrelease', False)), - 'internal' : 0, - 'featured' : 0, - 'free' : 0, - 'doubleup' : 0, - 'sticky' : 0, - } - # Internal - if self.config['TRACKERS'][self.tracker].get('internal', False) == True: - if meta['tag'] != "" and (meta['tag'][1:] in self.config['TRACKERS'][self.tracker].get('internal_groups', [])): - data['internal'] = 1 - - if region_id != 0: - data['region_id'] = region_id - if distributor_id != 0: - data['distributor_id'] = distributor_id - if meta.get('category') == "TV": - data['season_number'] = meta.get('season_int', '0') - data['episode_number'] = meta.get('episode_int', '0') - headers = { - 'User-Agent': f'Upload Assistant/2.1 ({platform.system()} {platform.release()})' + 'modq': await self.get_flag(meta, 'modq'), } - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip() - } - - if meta['debug'] == False: - response = requests.post(url=self.upload_url, files=files, data=data, headers=headers, params=params) - try: - console.print(response.json()) - except: - console.print("It may have uploaded, go check") - return - else: - console.print(f"[cyan]Request Data:") - console.print(data) - open_torrent.close() - - - - - async def search_existing(self, meta): - dupes = [] - console.print("[yellow]Searching for existing torrents on site...") - params = { - 'api_token' : self.config['TRACKERS'][self.tracker]['api_key'].strip(), - 'tmdbId' : meta['tmdb'], - 'categories[]' : await self.get_cat_id(meta['category']), - 'types[]' : await self.get_type_id(meta['type']), - 'resolutions[]' : await self.get_res_id(meta['resolution']), - 'name' : "" - } - if meta.get('edition', "") != "": - params['name'] = params['name'] + f" {meta['edition']}" - try: - response = requests.get(url=self.search_url, params=params) - response = response.json() - for each in response['data']: - result = [each][0]['attributes']['name'] - # difference = SequenceMatcher(None, meta['clean_name'], result).ratio() - # if difference >= 0.05: - dupes.append(result) - except: - console.print('[bold red]Unable to search for existing torrents on site. Either the site is down or your API key is incorrect') - await asyncio.sleep(5) + return data - return dupes \ No newline at end of file + # If the tracker has specific naming conventions, add them here; otherwise, remove this function + async def get_name(self, meta): + UNIT3D_TEMPLATE_name = meta['name'] + return {'name': UNIT3D_TEMPLATE_name} diff --git a/src/trackers/UTP.py b/src/trackers/UTP.py new file mode 100644 index 000000000..abdc812f6 --- /dev/null +++ b/src/trackers/UTP.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class UTP(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='UTP') + self.config = config + self.common = COMMON(config) + self.tracker = 'UTP' + self.source_flag = 'UTOPIA' + self.base_url = 'https://utp.to' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_category_id(self, meta): + category_name = meta['category'] + edition = meta.get('edition', '') + category_id = { + 'MOVIE': '1', + 'TV': '2', + 'FANRES': '3' + }.get(category_name, '0') + if category_name == 'MOVIE' and 'FANRES' in edition: + category_id = '3' + return {'category_id': category_id} + + async def get_resolution_id(self, meta): + resolution_id = { + '4320p': '1', + '2160p': '2', + '1080p': '3', + '1080i': '4' + }.get(meta['resolution'], '1') + return {'resolution_id': resolution_id} diff --git a/src/trackers/YOINK.py b/src/trackers/YOINK.py new file mode 100644 index 000000000..c8fd9101c --- /dev/null +++ b/src/trackers/YOINK.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class YOINK(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='YOINK') + self.config = config + self.common = COMMON(config) + self.tracker = 'YOINK' + self.source_flag = 'YOiNKED' + self.base_url = 'https://yoinked.org' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = ['YTS', 'YiFY', 'LAMA', 'MeGUSTA', 'NAHOM', 'GalaxyRG', 'RARBG', 'INFINITY'] + pass diff --git a/src/trackers/YUS.py b/src/trackers/YUS.py new file mode 100644 index 000000000..929414b66 --- /dev/null +++ b/src/trackers/YUS.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +from src.console import console +from src.trackers.COMMON import COMMON +from src.trackers.UNIT3D import UNIT3D + + +class YUS(UNIT3D): + def __init__(self, config): + super().__init__(config, tracker_name='YUS') + self.config = config + self.common = COMMON(config) + self.tracker = 'YUS' + self.source_flag = 'YuScene' + self.base_url = 'https://yu-scene.net' + self.id_url = f'{self.base_url}/api/torrents/' + self.upload_url = f'{self.base_url}/api/torrents/upload' + self.search_url = f'{self.base_url}/api/torrents/filter' + self.requests_url = f'{self.base_url}/api/requests/filter' + self.torrent_url = f'{self.base_url}/torrents/' + self.banned_groups = [] + pass + + async def get_additional_checks(self, meta): + should_continue = True + + disallowed_keywords = {'XXX', 'Erotic', 'Porn', 'Hentai', 'softcore'} + if any(keyword.lower() in disallowed_keywords for keyword in map(str.lower, meta['keywords'])): + console.print('[bold red]Adult animation not allowed at YUS.') + should_continue = False + + return should_continue + + async def get_type_id(self, meta, type=None, reverse=False, mapping_only=False): + type_id = { + 'DISC': '17', + 'REMUX': '2', + 'WEBDL': '4', + 'WEBRIP': '5', + 'HDTV': '6', + 'ENCODE': '3' + } + if mapping_only: + return type_id + elif reverse: + return {v: k for k, v in type_id.items()} + elif type is not None: + return {'type_id': type_id.get(type, '0')} + else: + meta_type = meta.get('type', '') + resolved_id = type_id.get(meta_type, '0') + return {'type_id': resolved_id} diff --git a/src/trackersetup.py b/src/trackersetup.py new file mode 100644 index 000000000..5ce56e051 --- /dev/null +++ b/src/trackersetup.py @@ -0,0 +1,805 @@ +import asyncio +import cli_ui +import httpx +import json +import os +import re +import sys + +from datetime import datetime, timedelta +from src.cleanup import cleanup, reset_terminal +from src.console import console + +from src.trackers.ACM import ACM +from src.trackers.AITHER import AITHER +from src.trackers.AL import AL +from src.trackers.ANT import ANT +from src.trackers.AR import AR +from src.trackers.ASC import ASC +from src.trackers.AZ import AZ +from src.trackers.BHD import BHD +from src.trackers.BHDTV import BHDTV +from src.trackers.BJS import BJS +from src.trackers.BLU import BLU +from src.trackers.BT import BT +from src.trackers.CBR import CBR +from src.trackers.CZ import CZ +from src.trackers.DC import DC +from src.trackers.DP import DP +from src.trackers.FF import FF +from src.trackers.FL import FL +from src.trackers.FNP import FNP +from src.trackers.FRIKI import FRIKI +from src.trackers.GPW import GPW +from src.trackers.HDB import HDB +from src.trackers.HDS import HDS +from src.trackers.HDT import HDT +from src.trackers.HHD import HHD +from src.trackers.HUNO import HUNO +from src.trackers.ITT import ITT +from src.trackers.LCD import LCD +from src.trackers.LDU import LDU +from src.trackers.LST import LST +from src.trackers.LT import LT +from src.trackers.MTV import MTV +from src.trackers.NBL import NBL +from src.trackers.OE import OE +from src.trackers.OTW import OTW +from src.trackers.PHD import PHD +from src.trackers.PT import PT +from src.trackers.PTER import PTER +from src.trackers.PTP import PTP +from src.trackers.PTS import PTS +from src.trackers.PTT import PTT +from src.trackers.R4E import R4E +from src.trackers.RAS import RAS +from src.trackers.RF import RF +from src.trackers.RTF import RTF +from src.trackers.SAM import SAM +from src.trackers.SHRI import SHRI +from src.trackers.SN import SN +from src.trackers.SP import SP +from src.trackers.SPD import SPD +from src.trackers.STC import STC +from src.trackers.THR import THR +from src.trackers.TIK import TIK +from src.trackers.TL import TL +from src.trackers.TTG import TTG +from src.trackers.TVC import TVC +from src.trackers.UHD import UHD +from src.trackers.ULCX import ULCX +from src.trackers.UTP import UTP +from src.trackers.YOINK import YOINK +from src.trackers.YUS import YUS + + +class TRACKER_SETUP: + def __init__(self, config): + self.config = config + # Add initialization details here + pass + + def trackers_enabled(self, meta): + from data.config import config + + if meta.get('trackers') is not None: + trackers = meta['trackers'] + else: + trackers = config['TRACKERS']['default_trackers'] + + if isinstance(trackers, str): + trackers = trackers.split(',') + + trackers = [str(s).strip().upper() for s in trackers] + + if meta.get('manual', False): + trackers.insert(0, "MANUAL") + + valid_trackers = [t for t in trackers if t in tracker_class_map or t == "MANUAL"] + removed_trackers = set(trackers) - set(valid_trackers) + + for tracker in removed_trackers: + print(f"Warning: Tracker '{tracker}' is not recognized and will be ignored.") + + return valid_trackers + + async def get_banned_groups(self, meta, tracker): + file_path = os.path.join(meta['base_dir'], 'data', 'banned', f'{tracker}_banned_groups.json') + + tracker_class = tracker_class_map.get(tracker.upper()) + tracker_instance = tracker_class(self.config) + try: + banned_url = tracker_instance.banned_url + except AttributeError: + return None + + # Check if we need to update + if not await self.should_update(file_path): + return file_path + + headers = { + 'Authorization': f"Bearer {self.config['TRACKERS'][tracker]['api_key'].strip()}", + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + all_data = [] + next_cursor = None + + async with httpx.AsyncClient() as client: + while True: + try: + # Add query parameters for pagination + params = {'cursor': next_cursor, 'per_page': 100} if next_cursor else {'per_page': 100} + response = await client.get(url=banned_url, headers=headers, params=params) + + if response.status_code == 200: + response_json = response.json() + + if isinstance(response_json, list): + # Directly add the list if it's the entire response + all_data.extend(response_json) + break # No pagination in this case + elif isinstance(response_json, dict): + page_data = response_json.get('data', []) + if not isinstance(page_data, list): + console.print(f"[red]Unexpected 'data' format: {type(page_data)}[/red]") + return None + + all_data.extend(page_data) + meta_info = response_json.get('meta', {}) + if not isinstance(meta_info, dict): + console.print(f"[red]Unexpected 'meta' format: {type(meta_info)}[/red]") + return None + + # Check if there is a next page + next_cursor = meta_info.get('next_cursor') + if not next_cursor: + break # Exit loop if there are no more pages + else: + console.print(f"[red]Unexpected response format: {type(response_json)}[/red]") + return None + elif response.status_code == 404: + console.print(f"Error: Tracker '{tracker}' returned 404 for the banned groups API.") + return None + else: + console.print(f"Error: Received status code {response.status_code} for tracker '{tracker}'.") + return None + + except httpx.RequestError as e: + console.print(f"[red]HTTP Request failed for tracker '{tracker}': {e}[/red]") + return None + except Exception as e: + console.print(f"[red]An unexpected error occurred: {e}[/red]") + return None + + if meta['debug']: + console.print("Total banned groups retrieved:", len(all_data)) + + if not all_data: + return "empty" + + await self.write_banned_groups_to_file(file_path, all_data, debug=meta['debug']) + + return file_path + + async def write_banned_groups_to_file(self, file_path, json_data, debug=False): + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + if not isinstance(json_data, list): + console.print("Invalid data format: expected a list of groups.") + return + + # Extract 'name' values from the list + names = [item['name'] for item in json_data if isinstance(item, dict) and 'name' in item] + names_csv = ', '.join(names) + file_content = { + "last_updated": datetime.now().strftime("%Y-%m-%d"), + "banned_groups": names_csv, + "raw_data": json_data + } + + await asyncio.to_thread(self._write_file, file_path, file_content) + if debug: + console.print(f"File '{file_path}' updated successfully with {len(names)} groups.") + except Exception as e: + console.print(f"An error occurred: {e}") + + def _write_file(self, file_path, data): + """ Blocking file write operation, runs in a background thread """ + with open(file_path, "w", encoding="utf-8") as file: + json.dump(data, file, indent=4) + + async def should_update(self, file_path): + try: + content = await asyncio.to_thread(self._read_file, file_path) + data = json.loads(content) + last_updated = datetime.strptime(data['last_updated'], "%Y-%m-%d") + return datetime.now() >= last_updated + timedelta(days=1) + except FileNotFoundError: + return True + except Exception as e: + console.print(f"Error reading file: {e}") + return True + + def _read_file(self, file_path): + """ Helper function to read the file in a blocking thread """ + with open(file_path, "r", encoding="utf-8") as file: + return file.read() + + async def check_banned_group(self, tracker, banned_group_list, meta): + result = False + if not meta['tag']: + return False + + group_tags = meta['tag'][1:].lower() + if 'taoe' in group_tags: + group_tags = 'taoe' + + if tracker.upper() in ("AITHER", "LST"): + file_path = await self.get_banned_groups(meta, tracker) + if file_path == "empty": + console.print(f"[bold red]No banned groups found for '{tracker}'.") + return False + if not file_path: + console.print(f"[bold red]Failed to load banned groups for '{tracker}'.") + return False + + # Load the banned groups from the file + try: + content = await asyncio.to_thread(self._read_file, file_path) + data = json.loads(content) + banned_groups = data.get("banned_groups", "") + if banned_groups: + banned_group_list = banned_groups.split(", ") + + except FileNotFoundError: + console.print(f"[bold red]Banned group file for '{tracker}' not found.") + return False + except json.JSONDecodeError: + console.print(f"[bold red]Failed to parse banned group file for '{tracker}'.") + return False + + for tag in banned_group_list: + if isinstance(tag, list): + if group_tags == tag[0].lower(): + console.print(f"[bold yellow]{meta['tag'][1:]}[/bold yellow][bold red] was found on [bold yellow]{tracker}'s[/bold yellow] list of banned groups.") + console.print(f"[bold red]NOTE: [bold yellow]{tag[1]}") + await asyncio.sleep(5) + result = True + else: + if group_tags == tag.lower(): + console.print(f"[bold yellow]{meta['tag'][1:]}[/bold yellow][bold red] was found on [bold yellow]{tracker}'s[/bold yellow] list of banned groups.") + await asyncio.sleep(5) + result = True + + if result: + if not meta['unattended'] or meta.get('unattended_confirm', False): + try: + if cli_ui.ask_yes_no(cli_ui.red, "Do you want to continue anyway?", default=False): + return False + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + return True + + return True + + return False + + async def write_internal_claims_to_file(self, file_path, data, debug=False): + try: + os.makedirs(os.path.dirname(file_path), exist_ok=True) + + if not isinstance(data, list): + console.print("Invalid data format: expected a list of claims.") + return + + extracted_data = [] + for item in data: + if not isinstance(item, dict) or 'attributes' not in item: + console.print(f"Skipping invalid item: {item}") + continue + + attributes = item['attributes'] + extracted_data.append({ + "title": attributes.get('title', 'Unknown'), + "season": attributes.get('season', 'Unknown'), + "tmdb_id": attributes.get('tmdb_id', 'Unknown'), + "resolutions": attributes.get('resolutions', []), + "types": attributes.get('types', []) + }) + + if not extracted_data: + if debug: + console.print("No valid claims found to write.") + return + + titles_csv = ', '.join([data['title'] for data in extracted_data]) + + file_content = { + "last_updated": datetime.now().strftime("%Y-%m-%d"), + "titles_csv": titles_csv, + "extracted_data": extracted_data, + "raw_data": data + } + + await asyncio.to_thread(self._write_file, file_path, file_content) + if debug: + console.print(f"File '{file_path}' updated successfully with {len(extracted_data)} claims.") + except Exception as e: + console.print(f"An error occurred: {e}") + + async def get_torrent_claims(self, meta, tracker): + file_path = os.path.join(meta['base_dir'], 'data', 'banned', f'{tracker}_claimed_releases.json') + tracker_class = tracker_class_map.get(tracker.upper()) + tracker_instance = tracker_class(self.config) + try: + claims_url = tracker_instance.claims_url + except AttributeError: + return None + + # Check if we need to update + if not await self.should_update(file_path): + return await self.check_tracker_claims(meta, tracker) + + headers = { + 'Authorization': f"Bearer {self.config['TRACKERS'][tracker]['api_key'].strip()}", + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + + all_data = [] + next_cursor = None + + async with httpx.AsyncClient() as client: + while True: + try: + # Add query parameters for pagination + params = {'cursor': next_cursor, 'per_page': 100} if next_cursor else {'per_page': 100} + response = await client.get(url=claims_url, headers=headers, params=params) + + if response.status_code == 200: + response_json = response.json() + page_data = response_json.get('data', []) + if not isinstance(page_data, list): + console.print(f"[red]Unexpected 'data' format: {type(page_data)}[/red]") + return False + + all_data.extend(page_data) + meta_info = response_json.get('meta', {}) + if not isinstance(meta_info, dict): + console.print(f"[red]Unexpected 'meta' format: {type(meta_info)}[/red]") + return False + + # Check if there is a next page + next_cursor = meta_info.get('next_cursor') + if not next_cursor: + break # Exit loop if there are no more pages + else: + console.print(f"[red]Error: Received status code {response.status_code}[/red]") + return False + + except httpx.RequestError as e: + console.print(f"[red]HTTP Request failed: {e}[/red]") + return False + except Exception as e: + console.print(f"[red]An unexpected error occurred: {e}[/red]") + return False + + if meta['debug']: + console.print("Total claims retrieved:", len(all_data)) + + if not all_data: + return False + + await self.write_internal_claims_to_file(file_path, all_data, debug=meta['debug']) + + return await self.check_tracker_claims(meta, tracker) + + async def check_tracker_claims(self, meta, tracker): + if isinstance(tracker, str): + trackers = [tracker.strip().upper()] + elif isinstance(tracker, list): + trackers = [s.upper() for s in tracker] + else: + console.print("[red]Invalid trackers input format.[/red]") + return False + + async def process_single_tracker(tracker_name): + try: + tracker_class = tracker_class_map.get(tracker_name.upper()) + if not tracker_class: + console.print(f"[red]Tracker {tracker_name} is not registered in tracker_class_map[/red]") + return False + + tracker_instance = tracker_class(self.config) + # Get name-to-ID mappings directly + type_mapping = await tracker_instance.get_type_id(meta, mapping_only=True) + type_name = meta.get('type', '') + type_ids = [type_mapping.get(type_name)] if type_name else [] + if None in type_ids: + console.print("[yellow]Warning: Type in meta not found in tracker type mapping.[/yellow]") + + resolution_mapping = await tracker_instance.get_resolution_id(meta, mapping_only=True) + resolution_name = meta.get('resolution', '') + resolution_ids = [resolution_mapping.get(resolution_name)] if resolution_name else [] + if None in resolution_ids: + console.print("[yellow]Warning: Resolution in meta not found in tracker resolution mapping.[/yellow]") + + tmdb_id = meta.get('tmdb', []) + if isinstance(tmdb_id, int): + tmdb_id = [tmdb_id] + elif isinstance(tmdb_id, str): + tmdb_id = [int(tmdb_id)] + elif isinstance(tmdb_id, list): + tmdb_id = [int(id) for id in tmdb_id] + else: + console.print(f"[red]Invalid TMDB ID format in meta: {tmdb_id}[/red]") + return False + + metaseason = meta.get('season_int') + if metaseason: + seasonint = int(metaseason) + file_path = os.path.join(meta['base_dir'], 'data', 'banned', f'{tracker_name}_claimed_releases.json') + if not os.path.exists(file_path): + console.print(f"[red]No claim data file found for {tracker_name}[/red]") + return False + + with open(file_path, 'r') as file: + extracted_data = json.load(file).get('extracted_data', []) + + for item in extracted_data: + title = item.get('title') + season = item.get('season') + api_tmdb_id = item.get('tmdb_id') + api_resolutions = item.get('resolutions', []) + api_types = item.get('types', []) + + if ( + api_tmdb_id in tmdb_id + and (meta['category'] == "MOVIE" or season == seasonint) + and all(res in api_resolutions for res in resolution_ids) + and all(typ in api_types for typ in type_ids) + ): + console.print(f"[green]Claimed match found at [cyan]{tracker}: [yellow]{title}, Season: {season}, TMDB ID: {api_tmdb_id}[/green]") + return True + + return False + + except Exception as e: + console.print(f"[red]Error processing tracker {tracker_name}: {e}[/red]", highlight=True) + import traceback + console.print(traceback.format_exc()) + return False + + results = await asyncio.gather(*[process_single_tracker(tracker) for tracker in trackers]) + match_found = any(results) + + return match_found + + async def get_tracker_requests(self, meta, tracker, url): + if meta['debug']: + console.print(f"[bold green]Searching for existing requests on {tracker}[/bold green]") + requests = [] + headers = { + 'Authorization': f"Bearer {self.config['TRACKERS'][tracker]['api_key'].strip()}", + 'Accept': 'application/json' + } + params = { + 'tmdb': meta['tmdb'], + } + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.get(url=url, headers=headers, params=params) + if response.status_code == 200: + data = response.json() + if 'data' in data and isinstance(data['data'], list): + results_list = data['data'] + elif 'results' in data and isinstance(data['results'], list): + results_list = data['results'] + else: + console.print(f"[bold red]Unexpected response format: {type(data)}[/bold red]") + return requests + + try: + for each in results_list: + attributes = each + result = { + 'id': attributes.get('id'), + 'name': attributes.get('name'), + 'description': attributes.get('description'), + 'category': attributes.get('category_id'), + 'type': attributes.get('type_id'), + 'resolution': attributes.get('resolution_id'), + 'bounty': attributes.get('bounty'), + 'status': attributes.get('status'), + 'claimed': attributes.get('claimed'), + 'season': attributes.get('season_number'), + 'episode': attributes.get('episode_number'), + } + requests.append(result) + except Exception as e: + console.print(f"[bold red]Error processing response data: {e}[/bold red]") + return requests + else: + console.print(f"[bold red]Failed to search torrents. HTTP Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out after 5 seconds") + except httpx.RequestError as e: + console.print(f"[bold red]Unable to search for existing torrents: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + + return requests + + async def bhd_request_check(self, meta, tracker, url): + if 'BHD' not in self.config['TRACKERS'] or not self.config['TRACKERS']['BHD'].get('api_key'): + console.print("[red]BHD API key not configured. Skipping BHD request check.[/red]") + return False + if meta['debug']: + console.print(f"[bold green]Searching for existing requests on {tracker}[/bold green]") + requests = [] + params = { + 'action': 'search', + 'tmdb_id': f"{meta['category'].lower()}/{meta['tmdb_id']}", + } + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url=url, params=params) + if response.status_code == 200: + data = response.json() + if 'data' in data and isinstance(data['data'], list): + results_list = data['data'] + elif 'results' in data and isinstance(data['results'], list): + results_list = data['results'] + else: + console.print(f"[bold red]Unexpected response format: {type(data)}[/bold red]") + console.print(f"[bold red]Full response: {data}[/bold red]") + return requests + + try: + for each in results_list: + attributes = each + result = { + 'id': attributes.get('id'), + 'name': attributes.get('name'), + 'type': attributes.get('source'), + 'resolution': attributes.get('type'), + 'dv': attributes.get('dv'), + 'hdr': attributes.get('hdr'), + 'bounty': attributes.get('bounty'), + 'status': attributes.get('status'), + 'internal': attributes.get('internal'), + 'url': attributes.get('url'), + } + requests.append(result) + except Exception as e: + console.print(f"[bold red]Error processing response data: {e}[/bold red]") + console.print(f"[bold red]Response data: {data}[/bold red]") + return requests + else: + console.print(f"[bold red]Failed to search torrents. HTTP Status: {response.status_code}") + except httpx.TimeoutException: + console.print("[bold red]Request timed out after 5 seconds") + except httpx.RequestError as e: + console.print(f"[bold red]Unable to search for existing torrents: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}") + # console.print(f"Debug: BHD requests found: {requests}") + return requests + + async def tracker_request(self, meta, tracker): + if isinstance(tracker, str): + trackers = [tracker.strip().upper()] + elif isinstance(tracker, list): + trackers = [s.upper() for s in tracker] + else: + console.print("[red]Invalid trackers input format.[/red]") + return False + + async def process_single_tracker(tracker): + tracker_class = tracker_class_map.get(tracker) + if not tracker_class: + console.print(f"[red]Tracker {tracker} is not registered in tracker_class_map[/red]") + return False + + tracker_instance = tracker_class(self.config) + try: + url = tracker_instance.requests_url + except AttributeError: + # tracker without requests url not supported + return + if tracker.upper() == "BHD": + requests = await self.bhd_request_check(meta, tracker, url) + else: + requests = await self.get_tracker_requests(meta, tracker, url) + type_mapping = await tracker_instance.get_type_id(meta, mapping_only=True) + type_name = meta.get('type', '') + type_ids = [type_mapping.get(type_name)] if type_name else [] + if None in type_ids: + console.print("[yellow]Warning: Type in meta not found in tracker type mapping.[/yellow]") + + resolution_mapping = await tracker_instance.get_resolution_id(meta, mapping_only=True) + resolution_name = meta.get('resolution', '') + resolution_ids = [resolution_mapping.get(resolution_name)] if resolution_name else [] + if None in resolution_ids: + console.print("[yellow]Warning: Resolution in meta not found in tracker resolution mapping.[/yellow]") + + category_mapping = await tracker_instance.get_category_id(meta, mapping_only=True) + category_name = meta.get('category', '') + category_ids = [category_mapping.get(category_name)] if category_name else [] + if None in category_ids: + console.print("[yellow]Warning: Some categories in meta not found in tracker category mapping.[/yellow]") + + tmdb_id = meta.get('tmdb', []) + if isinstance(tmdb_id, int): + tmdb_id = [tmdb_id] + elif isinstance(tmdb_id, str): + tmdb_id = [int(tmdb_id)] + elif isinstance(tmdb_id, list): + tmdb_id = [int(id) for id in tmdb_id] + else: + console.print(f"[red]Invalid TMDB ID format in meta: {tmdb_id}[/red]") + return False + for each in requests: + type_name = False + resolution = False + season = False + episode = False + double_check = False + api_id = each.get('id') + api_category = each.get('category') + api_name = each.get('name') + api_type = each.get('type') + api_bounty = each.get('bounty') + api_status = each.get('status') + if "BHD" not in tracker: + if str(api_type) in [str(tid) for tid in type_ids]: + type_name = True + elif api_type is None: + type_name = True + double_check = True + api_resolution = each.get('resolution') + if str(api_resolution) in [str(rid) for rid in resolution_ids]: + resolution = True + elif api_resolution is None: + resolution = True + double_check = True + api_claimed = each.get('claimed') + api_description = each.get('description') + if meta['category'] == "TV": + api_season = int(each.get('season')) if each.get('season') is not None else 0 + if api_season and meta.get('season_int') and api_season == meta.get('season_int'): + season = True + api_episode = int(each.get('episode')) if each.get('episode') is not None else 0 + if api_episode and meta.get('episode_int') and api_episode == meta.get('episode_int'): + episode = True + if str(api_category) in [str(cid) for cid in category_ids]: + new_url = re.sub(r'/api/requests/filter$', f'/requests/{api_id}', url) + if meta.get('category') == "MOVIE" and type_name and resolution and not api_claimed: + console.print(f"[bold blue]Found exact request match on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] and with status [bold yellow]{api_status}[/bold yellow][/bold blue]") + console.print(f"[bold blue]Claimed status:[/bold blue] [bold yellow]{api_claimed}[/bold yellow]") + console.print(f"[bold green]{api_name}:[/bold green] {new_url}") + console.print() + if double_check: + console.print("[bold red]Type and/or resolution was set to ANY, double check any description requirements:[/bold red]") + console.print(f"[bold yellow]Request desc:[/bold yellow] {api_description[:100]}") + console.print() + elif meta.get('category') == "TV" and season and episode and type_name and resolution and not api_claimed: + console.print(f"[bold blue]Found exact request match on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] and with status [bold yellow]{api_status}[/bold yellow][/bold blue]") + console.print(f"[bold blue]Claimed status:[/bold blue] [bold yellow]{api_claimed}[/bold yellow]") + console.print(f"[bold yellow]{api_name}[/bold yellow] - [bold yellow]S{api_season:02d} E{api_episode:02d}:[/bold yellow] {new_url}") + console.print() + if double_check: + console.print("[bold red]Type and/or resolution was set to ANY, double check any description requirements:[/bold red]") + console.print(f"[bold yellow]Request desc:[/bold yellow] {api_description[:100]}") + console.print() + else: + console.print(f"[bold blue]Found request on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] and with status [bold yellow]{api_status}[/bold yellow][/bold blue]") + console.print(f"[bold blue]Claimed status:[/bold blue] [bold yellow]{api_claimed}[/bold yellow]") + if meta.get('category') == "MOVIE": + console.print(f"[bold yellow]{api_name}:[/bold yellow] {new_url}") + else: + console.print(f"[bold yellow]{api_name}[/bold yellow] - [bold yellow]S{api_season:02d} E{api_episode:02d}:[/bold yellow] {new_url}") + console.print(f"[bold green]Request desc: {api_description[:100]}[/bold green]") + console.print() + else: + unclaimed = each.get('status') == 1 + internal = each.get('internal') == 1 + claimed_status = "" + if each.get('status') == 1: + claimed_status = "Unfilled" + elif each.get('status') == 2: + claimed_status = "Claimed" + elif each.get('status') == 3: + claimed_status = "Pending" + dv = False + hdr = False + season = False + meta_hdr = meta.get('HDR', '') + is_season = re.search(r'S\d{2}', api_name) + if is_season and is_season == meta.get('season'): + season = True + if each.get('dv') and meta_hdr == "DV": + dv = True + if each.get('hdr') and meta_hdr in ("HDR10", "HDR10+", "HDR"): + hdr = True + if not each.get('dv') and "DV" not in meta_hdr: + dv = True + if not each.get('hdr') and meta_hdr not in ("HDR10", "HDR10+", "HDR"): + hdr = True + if 'remux' in each.get('resolution', '').lower(): + if 'uhd' in each.get('resolution', '').lower() and meta.get('resolution') == "2160p" and meta.get('type') == "REMUX": + resolution = True + type_name = True + elif 'uhd' not in each.get('resolution', '').lower() and meta.get('resolution') == "1080p" and meta.get('type') == "REMUX": + resolution = True + type_name = True + elif 'remux' not in each.get('resolution', '').lower() and meta.get('is_disc') == "BDMV": + if 'uhd' in each.get('resolution', '').lower() and meta.get('resolution') == "2160p": + resolution = True + type_name = True + elif 'uhd' not in each.get('resolution', '').lower() and meta.get('resolution') == "1080p": + resolution = True + type_name = True + elif each.get('resolution') == meta.get('resolution'): + resolution = True + if 'Blu-ray' in each.get('type') and meta.get('type') == "ENCODE": + type_name = True + elif 'WEB' in each.get('type') and 'WEB' in meta.get('type'): + type_name = True + if meta.get('category') == "MOVIE" and type_name and resolution and unclaimed and not internal and dv and hdr: + console.print(f"[bold blue]Found exact request match on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] and with status [bold yellow]{claimed_status}[/bold yellow][/bold blue]") + console.print(f"[bold green]{api_name}:[/bold green] {each.get('url')}") + console.print() + if meta.get('category') == "MOVIE" and type_name and resolution and unclaimed and not internal and not dv and not hdr and 'uhd' in each.get('resolution').lower(): + console.print(f"[bold blue]Found request match on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] with mismatched HDR or DV[/bold blue]") + console.print(f"[bold green]{api_name}:[/bold green] {each.get('url')}") + console.print() + if meta.get('category') == "TV" and season and type_name and resolution and unclaimed and not internal and dv and hdr: + console.print(f"[bold blue]Found exact request match on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] and with status [bold yellow]{claimed_status}[/bold yellow][/bold blue]") + console.print(f"[bold yellow]{api_name}[/bold yellow] - [bold yellow]{meta.get('season')}:[/bold yellow] {each.get('url')}") + console.print() + if meta.get('category') == "TV" and season and type_name and resolution and unclaimed and not internal and not dv and not hdr: + console.print(f"[bold blue]Found request match on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] with mismatched HDR or DV[/bold blue]") + console.print(f"[bold yellow]{api_name}[/bold yellow] - [bold yellow]{meta.get('season')}:[/bold yellow] {each.get('url')}") + console.print() + else: + console.print(f"[bold blue]Found request on [bold yellow]{tracker}[/bold yellow] with bounty [bold yellow]{api_bounty}[/bold yellow] and with status [bold yellow]{claimed_status}[/bold yellow][/bold blue]") + if internal: + console.print("[bold red]Request is internal only[/bold red]") + console.print(f"[bold yellow]{api_name}[/bold yellow] - {each.get('url')}") + console.print() + + return requests + + results = await asyncio.gather(*[process_single_tracker(tracker) for tracker in trackers]) + match_found = any(results) + + return match_found + + +tracker_class_map = { + 'ACM': ACM, 'AITHER': AITHER, 'AL': AL, 'ANT': ANT, 'AR': AR, 'ASC': ASC, 'AZ': AZ, 'BHD': BHD, 'BHDTV': BHDTV, 'BJS': BJS, 'BLU': BLU, 'BT': BT, 'CBR': CBR, + 'CZ': CZ, 'DC': DC, 'DP': DP, 'FNP': FNP, 'FF': FF, 'FL': FL, 'FRIKI': FRIKI, 'GPW': GPW, 'HDB': HDB, 'HDS': HDS, 'HDT': HDT, 'HHD': HHD, 'HUNO': HUNO, 'ITT': ITT, + 'LCD': LCD, 'LDU': LDU, 'LST': LST, 'LT': LT, 'MTV': MTV, 'NBL': NBL, 'OE': OE, 'OTW': OTW, 'PHD': PHD, 'PT': PT, 'PTP': PTP, 'PTER': PTER, 'PTS': PTS, 'PTT': PTT, + 'R4E': R4E, 'RAS': RAS, 'RF': RF, 'RTF': RTF, 'SAM': SAM, 'SHRI': SHRI, 'SN': SN, 'SP': SP, 'SPD': SPD, 'STC': STC, 'THR': THR, + 'TIK': TIK, 'TL': TL, 'TVC': TVC, 'TTG': TTG, 'UHD': UHD, 'ULCX': ULCX, 'UTP': UTP, 'YOINK': YOINK, 'YUS': YUS +} + +api_trackers = { + 'ACM', 'AITHER', 'AL', 'BHD', 'BLU', 'CBR', 'DP', 'FNP', 'FRIKI', 'HHD', 'HUNO', 'ITT', 'LCD', 'LDU', 'LST', 'LT', + 'OE', 'OTW', 'PT', 'PTT', 'RAS', 'RF', 'R4E', 'SAM', 'SHRI', 'SP', 'STC', 'TIK', 'UHD', 'ULCX', 'UTP', 'YOINK', 'YUS' +} + +other_api_trackers = { + 'ANT', 'BHDTV', 'DC', 'GPW', 'NBL', 'RTF', 'SN', 'SPD', 'TL', 'TVC' +} + +http_trackers = { + 'AR', 'ASC', 'AZ', 'BJS', 'BT', 'CZ', 'FF', 'FL', 'HDB', 'HDS', 'HDT', 'MTV', 'PHD', 'PTER', 'PTS', 'TTG' +} diff --git a/src/trackerstatus.py b/src/trackerstatus.py new file mode 100644 index 000000000..c30e35663 --- /dev/null +++ b/src/trackerstatus.py @@ -0,0 +1,220 @@ +import asyncio +import cli_ui +import copy +import os +import sys + +from torf import Torrent + +from data.config import config +from src.cleanup import cleanup, reset_terminal +from src.clients import Clients +from src.console import console +from src.dupe_checking import filter_dupes +from src.imdb import get_imdb_info_api +from src.torrentcreate import create_base_from_existing_torrent +from src.trackers.PTP import PTP +from src.trackersetup import TRACKER_SETUP, tracker_class_map, http_trackers +from src.uphelper import UploadHelper + + +async def process_all_trackers(meta): + tracker_status = {} + successful_trackers = 0 + client = Clients(config=config) + tracker_setup = TRACKER_SETUP(config=config) + helper = UploadHelper() + meta_lock = asyncio.Lock() # noqa F841 + for tracker in meta['trackers']: + if 'tracker_status' not in meta: + meta['tracker_status'] = {} + if tracker not in meta['tracker_status']: + meta['tracker_status'][tracker] = {} + + async def process_single_tracker(tracker_name, shared_meta): + nonlocal successful_trackers + local_meta = copy.deepcopy(shared_meta) # Ensure each task gets its own copy of meta + local_tracker_status = {'banned': False, 'skipped': False, 'dupe': False, 'upload': False} + disctype = local_meta.get('disctype', None) + + if local_meta['name'].endswith('DUPE?'): + local_meta['name'] = local_meta['name'].replace(' DUPE?', '') + + if tracker_name == "MANUAL": + local_tracker_status['upload'] = True + successful_trackers += 1 + + if tracker_name in tracker_class_map: + tracker_class = tracker_class_map[tracker_name](config=config) + if tracker_name in http_trackers: + login = await tracker_class.validate_credentials(meta) + if not login: + local_tracker_status['skipped'] = True + if isinstance(login, str) and login: + local_meta[f'{tracker_name}_secret_token'] = login + meta[f'{tracker_name}_secret_token'] = login + if tracker_name in {"THR", "PTP"}: + if local_meta.get('imdb_id', 0) == 0: + while True: + if local_meta.get('unattended', False): + local_meta['imdb_id'] = 0 + local_tracker_status['skipped'] = True + break + try: + imdb_id = cli_ui.ask_string( + f"Unable to find IMDB id, please enter e.g.(tt1234567) or press Enter to skip uploading to {tracker_name}:" + ) + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + + if imdb_id is None or imdb_id.strip() == "": + local_meta['imdb_id'] = 0 + break + + imdb_id = imdb_id.strip().lower() + if imdb_id.startswith("tt") and imdb_id[2:].isdigit(): + local_meta['imdb_id'] = int(imdb_id[2:]) + local_meta['imdb'] = str(imdb_id[2:].zfill(7)) + local_meta['imdb_info'] = await get_imdb_info_api(local_meta['imdb_id'], local_meta) + break + else: + cli_ui.error("Invalid IMDB ID format. Expected format: tt1234567") + + result = await tracker_setup.check_banned_group(tracker_class.tracker, tracker_class.banned_groups, local_meta) + if result: + local_tracker_status['banned'] = True + else: + local_tracker_status['banned'] = False + + if local_meta['tracker_status'][tracker_name].get('skip_upload'): + local_tracker_status['skipped'] = True + elif 'skipped' not in local_meta and local_tracker_status['skipped'] is None: + local_tracker_status['skipped'] = False + + if not local_tracker_status['banned'] and not local_tracker_status['skipped']: + claimed = await tracker_setup.get_torrent_claims(local_meta, tracker_name) + if claimed: + local_tracker_status['skipped'] = True + else: + local_tracker_status['skipped'] = False + + if tracker_name not in {"PTP"} and not local_tracker_status['skipped']: + dupes = await tracker_class.search_existing(local_meta, disctype) + elif tracker_name == "PTP": + ptp = PTP(config=config) + groupID = await ptp.get_group_by_imdb(local_meta['imdb']) + meta['ptp_groupID'] = groupID + dupes = await ptp.search_existing(groupID, local_meta, disctype) + + if tracker_name == "ASC" and meta.get('anon', 'false'): + console.print("PT: [yellow]Aviso: Você solicitou um upload anônimo, mas o ASC não suporta essa opção.[/yellow][red] O envio não será anônimo.[/red]") + console.print("EN: [yellow]Warning: You requested an anonymous upload, but ASC does not support this option.[/yellow][red] The upload will not be anonymous.[/red]") + + if ('skipping' not in local_meta or local_meta['skipping'] is None) and not local_tracker_status['skipped']: + dupes = await filter_dupes(dupes, local_meta, tracker_name) + local_meta, is_dupe = await helper.dupe_check(dupes, local_meta, tracker_name) + if is_dupe: + local_tracker_status['dupe'] = True + elif 'skipping' in local_meta: + local_tracker_status['skipped'] = True + + if tracker_name == "MTV": + if not local_tracker_status['banned'] and not local_tracker_status['skipped'] and not local_tracker_status['dupe']: + tracker_config = config['TRACKERS'].get(tracker_name, {}) + if str(tracker_config.get('skip_if_rehash', 'false')).lower() == "true": + torrent_path = os.path.abspath(f"{local_meta['base_dir']}/tmp/{local_meta['uuid']}/BASE.torrent") + if not os.path.exists(torrent_path): + check_torrent = await client.find_existing_torrent(local_meta) + if check_torrent: + console.print(f"[yellow]Existing torrent found on {check_torrent}[yellow]") + await create_base_from_existing_torrent(check_torrent, local_meta['base_dir'], local_meta['uuid']) + torrent = Torrent.read(torrent_path) + if torrent.piece_size > 8388608: + console.print("[yellow]No existing torrent found with piece size lesser than 8MB[yellow]") + local_tracker_status['skipped'] = True + elif os.path.exists(torrent_path): + torrent = Torrent.read(torrent_path) + if torrent.piece_size > 8388608: + console.print("[yellow]Existing torrent found with piece size greater than 8MB[yellow]") + local_tracker_status['skipped'] = True + + we_already_asked = local_meta.get('we_asked', False) + + if not local_meta['debug']: + if not local_tracker_status['banned'] and not local_tracker_status['skipped'] and not local_tracker_status['dupe']: + if not local_meta.get('unattended', False): + console.print(f"[bold yellow]Tracker '{tracker_name}' passed all checks.") + if ( + not local_meta['unattended'] + or (local_meta['unattended'] and local_meta.get('unattended_confirm', False)) + ) and not we_already_asked: + edit_choice = "y" if local_meta['unattended'] else input("Enter 'y' to upload, or press enter to skip uploading:") + if edit_choice.lower() == 'y': + local_tracker_status['upload'] = True + successful_trackers += 1 + else: + local_tracker_status['upload'] = False + else: + local_tracker_status['upload'] = True + successful_trackers += 1 + else: + local_tracker_status['upload'] = True + successful_trackers += 1 + meta['we_asked'] = False + + return tracker_name, local_tracker_status + + if meta.get('unattended', False): + searching_trackers = [name for name in meta['trackers'] if name in tracker_class_map] + if searching_trackers: + console.print(f"[yellow]Searching for existing torrents on: {', '.join(searching_trackers)}...") + tasks = [process_single_tracker(tracker_name, meta) for tracker_name in meta['trackers']] + results = await asyncio.gather(*tasks) + + # Collect passed trackers and skip reasons + passed_trackers = [] + dupe_trackers = [] + skipped_trackers = [] + + for tracker_name, status in results: + tracker_status[tracker_name] = status + if not status['banned'] and not status['skipped'] and not status['dupe']: + passed_trackers.append(tracker_name) + elif status['dupe']: + dupe_trackers.append(tracker_name) + elif status['skipped']: + skipped_trackers.append(tracker_name) + + if skipped_trackers: + console.print(f"[red]Trackers skipped due to conditions: [bold yellow]{', '.join(skipped_trackers)}[/bold yellow].") + if dupe_trackers: + console.print(f"[red]Found potential dupes on: [bold yellow]{', '.join(dupe_trackers)}[/bold yellow].") + if passed_trackers: + console.print(f"[bold green]Trackers passed all checks: [bold yellow]{', '.join(passed_trackers)}") + else: + passed_trackers = [] + for tracker_name in meta['trackers']: + if tracker_name in tracker_class_map: + console.print(f"[yellow]Searching for existing torrents on {tracker_name}...") + tracker_name, status = await process_single_tracker(tracker_name, meta) + tracker_status[tracker_name] = status + if not status['banned'] and not status['skipped'] and not status['dupe']: + passed_trackers.append(tracker_name) + + if meta['debug']: + console.print("\n[bold]Tracker Processing Summary:[/bold]") + for t_name, status in tracker_status.items(): + banned_status = 'Yes' if status['banned'] else 'No' + skipped_status = 'Yes' if status['skipped'] else 'No' + dupe_status = 'Yes' if status['dupe'] else 'No' + upload_status = 'Yes' if status['upload'] else 'No' + console.print(f"Tracker: {t_name} | Banned: {banned_status} | Skipped: {skipped_status} | Dupe: {dupe_status} | [yellow]Upload:[/yellow] {upload_status}") + console.print(f"\n[bold]Trackers Passed all Checks:[/bold] {successful_trackers}") + print() + console.print("[bold red]DEBUG MODE does not upload to sites") + + meta['tracker_status'] = tracker_status + return successful_trackers diff --git a/src/tvdb.py b/src/tvdb.py new file mode 100644 index 000000000..8de961d22 --- /dev/null +++ b/src/tvdb.py @@ -0,0 +1,781 @@ +import httpx +import re +from src.console import console +from data.config import config + +config = config + + +async def get_tvdb_episode_data(base_dir, token, tvdb_id, season, episode, api_key=None, retry_attempted=False, debug=False): + if debug: + console.print(f"[cyan]Fetching TVDb episode data for S{season}E{episode}...[/cyan]") + + url = f"https://api4.thetvdb.com/v4/series/{tvdb_id}/episodes/default" + params = { + "page": 1, + "season": season, + "episodeNumber": episode + } + headers = { + "accept": "application/json", + "Authorization": f"Bearer {token}" + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, headers=headers, timeout=30.0) + + # Handle unauthorized responses + if response.status_code == 401: + # Only attempt a retry once to prevent infinite loops + if api_key and not retry_attempted: + console.print("[yellow]Unauthorized access. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + # Retry the request with the new token + return await get_tvdb_episode_data( + base_dir, new_token, tvdb_id, season, episode, api_key, True + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + else: + console.print("[red]Unauthorized access to TVDb API[/red]") + return None + + response.raise_for_status() + data = response.json() + + # Check for "Unauthorized" message in response body + if data.get("message") == "Unauthorized": + if api_key and not retry_attempted: + console.print("[yellow]Token invalid or expired. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + return await get_tvdb_episode_data( + base_dir, new_token, tvdb_id, season, episode, api_key, True + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + else: + console.print("[red]Unauthorized response from TVDb API[/red]") + return None + + if data.get("status") == "success" and data.get("data") and data["data"].get("episodes"): + episode_data = data["data"]["episodes"][0] + series_data = data["data"].get("series", {}) + + result = { + "episode_name": episode_data.get("name", ""), + "overview": episode_data.get("overview", ""), + "season_number": episode_data.get("seasonNumber", season), + "episode_number": episode_data.get("number", episode), + "air_date": episode_data.get("aired", ""), + "season_name": episode_data.get("seasonName", ""), + "series_name": series_data.get("name", ""), + "series_overview": series_data.get("overview", ""), + 'series_year': series_data.get("year", ""), + } + + if debug: + console.print(f"[green]Found episode: {result['season_name']} - S{result['season_number']}E{result['episode_number']} - {result['episode_name']}[/green] - {result['air_date']}") + console.print(f"[yellow]Overview: {result['overview']}") + console.print(f"[yellow]Series: {result['series_name']} - {result['series_overview']}[/yellow]") + return result + else: + if debug: + console.print(f"[yellow]No TVDB episode data found for S{season}E{episode}[/yellow]") + return None + + except httpx.HTTPStatusError as e: + console.print(f"[red]HTTP error occurred: {e.response.status_code} - {e.response.text}[/red]") + return None + except httpx.RequestError as e: + console.print(f"[red]Request error occurred: {e}[/red]") + return None + except Exception as e: + console.print(f"[red]Error fetching TVDb episode data: {e}[/red]") + return None + + +async def get_tvdb_token(api_key, base_dir): + console.print("[cyan]Authenticating with TVDb API...[/cyan]") + + url = "https://api4.thetvdb.com/v4/login" + headers = { + "accept": "application/json", + "Content-Type": "application/json" + } + payload = { + "apikey": api_key, + "pin": "string" # Default value as specified in the example + } + + try: + async with httpx.AsyncClient() as client: + response = await client.post(url, json=payload, headers=headers, timeout=30.0) + response.raise_for_status() + data = response.json() + + if data.get("status") == "success" and data.get("data") and data["data"].get("token"): + token = data["data"]["token"] + console.print("[green]Successfully authenticated with TVDb[/green]") + console.print(f"[bold yellow]New TVDb token: {token[:10]}...[/bold yellow]") + + # Update the token in the in-memory configuration + config['DEFAULT']['tvdb_token'] = f'"{token}"' + + # Save the updated config to disk + try: + # Get the config file path + config_path = f"{base_dir}/data/config.py" + + # Read the current config file + with open(config_path, 'r', encoding='utf-8') as file: + config_data = file.read() + + token_pattern = '"tvdb_token":' + if token_pattern in config_data: + # Find the line with tvdb_token + lines = config_data.splitlines() + for i, line in enumerate(lines): + if token_pattern in line: + # Split the line at the colon and keep everything before it + prefix = line.split(':', 1)[0] + # Create a new line with the updated token + lines[i] = f'{prefix}: "{token}",' + break + + # Rejoin the lines and write back to the file + new_config_data = '\n'.join(lines) + with open(config_path, 'w', encoding='utf-8') as file: + file.write(new_config_data) + + console.print(f"[bold green]TVDb token successfully saved to {config_path}[/bold green]") + else: + console.print("[yellow]Warning: Could not find tvdb_token in configuration file[/yellow]") + console.print("[yellow]The token will be used for this session only.[/yellow]") + + except Exception as e: + console.print(f"[yellow]Warning: Could not update TVDb token in configuration file: {e}[/yellow]") + console.print("[yellow]The token will be used for this session only.[/yellow]") + + return token + else: + console.print("[red]Failed to get TVDb token: Invalid response format[/red]") + return None + + except httpx.HTTPStatusError as e: + console.print(f"[red]HTTP error occurred during TVDb authentication: {e.response.status_code} - {e.response.text}[/red]") + return None + except httpx.RequestError as e: + console.print(f"[red]Request error occurred during TVDb authentication: {e}[/red]") + return None + except Exception as e: + console.print(f"[red]Error authenticating with TVDb: {e}[/red]") + return None + + +async def get_tvdb_series_episodes(base_dir, token, tvdb_id, season, episode, api_key=None, retry_attempted=False, debug=False): + if debug: + console.print(f"[cyan]Fetching episode list for series ID {tvdb_id}...[/cyan]") + + url = f"https://api4.thetvdb.com/v4/series/{tvdb_id}/extended?meta=episodes&short=false" + headers = { + "accept": "application/json", + "Authorization": f"Bearer {token}" + } + + all_episodes = [] + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=30.0) + + # Handle unauthorized responses + if response.status_code == 401: + # Only attempt a retry once to prevent infinite loops + if api_key and not retry_attempted: + console.print("[yellow]Unauthorized access. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + # Retry the request with the new token + return await get_tvdb_series_episodes( + base_dir, new_token, tvdb_id, season, episode, api_key, True + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return (season, episode) + else: + console.print("[red]Unauthorized access to TVDb API[/red]") + return (season, episode) + + response.raise_for_status() + data = response.json() + + # Check for "Unauthorized" message in response body + if data.get("message") == "Unauthorized": + if api_key and not retry_attempted: + console.print("[yellow]Token invalid or expired. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + return await get_tvdb_series_episodes( + base_dir, new_token, tvdb_id, season, episode, api_key, True + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return (season, episode) + else: + console.print("[red]Unauthorized response from TVDb API[/red]") + return (season, episode) + + if data.get("status") == "success" and data.get("data"): + episodes = data["data"].get("episodes", []) + all_episodes = episodes + + if not all_episodes: + if debug: + console.print(f"[yellow]No episodes found for TVDB series ID {tvdb_id}[/yellow]") + return (season, episode) + + if debug: + console.print(f"[cyan]Looking for season {season} episode {episode} in series {tvdb_id}[/cyan]") + + # Process and organize episode data + episodes_by_season = {} + absolute_mapping = {} # Map absolute numbers to season/episode + + # Sort by aired date first (if available) + def get_aired_date(ep): + aired = ep.get("aired") + # Return default value if aired is None or not present + if aired is None: + return "9999-99-99" + return aired + + all_episodes.sort(key=get_aired_date) + + for ep in all_episodes: + season_number = ep.get("seasonNumber") + episode_number = ep.get("number") + absolute_episode_count = ep.get("absoluteNumber") + + # Ensure season_number is valid and convert to int if needed + if season_number is not None: + try: + season_number = int(season_number) + except (ValueError, TypeError): + console.print(f"[yellow]Invalid season number: {season_number}, skipping episode[/yellow]") + continue + else: + console.print(f"[yellow]Missing season number for episode {ep.get('name', 'Unknown')}, skipping[/yellow]") + continue + + # Ensure episode_number is valid + if episode_number is not None: + try: + episode_number = int(episode_number) + except (ValueError, TypeError): + console.print(f"[yellow]Invalid episode number: {episode_number}, skipping episode[/yellow]") + continue + + # Handle special seasons (e.g., season 0) + is_special = season_number == 0 + + if not is_special: + # Store mapping of absolute number to season/episode + absolute_mapping[absolute_episode_count] = { + "season": season_number, + "episode": episode_number, + "episode_data": ep + } + + episode_data = { + "id": ep.get("id"), + "name": ep.get("name", ""), + "overview": ep.get("overview", ""), + "seasonNumber": season_number, + "episodeNumber": episode_number, + "absoluteNumber": absolute_episode_count if not is_special else None, + "aired": ep.get("aired"), + "runtime": ep.get("runtime"), + "imageUrl": ep.get("image"), + "thumbUrl": ep.get("thumbnail"), + "isMovie": ep.get("isMovie", False), + "airsAfterSeason": ep.get("airsAfterSeason"), + "airsBeforeSeason": ep.get("airsBeforeSeason"), + "airsBeforeEpisode": ep.get("airsBeforeEpisode"), + "productionCode": ep.get("productionCode", ""), + "finaleType": ep.get("finaleType", ""), + "year": ep.get("year") + } + + # Create a season entry if it doesn't exist + if season_number not in episodes_by_season: + episodes_by_season[season_number] = [] + + # Add the episode to its season + episodes_by_season[season_number].append(episode_data) + + # Sort episodes within each season by episode number + for s in episodes_by_season: + valid_episodes = [ep for ep in episodes_by_season[s] if ep["episodeNumber"] is not None] + episodes_by_season[s] = sorted(valid_episodes, key=lambda ep: ep["episodeNumber"]) + + # If season and episode were provided, try to find the matching episode + if season is not None and episode is not None: + found_episode = None + + # Ensure season is an integer + try: + season = int(season) + except (ValueError, TypeError): + if debug: + console.print(f"[yellow]Invalid season number provided: {season}, using as-is[/yellow]") + + if debug: + console.print(f"[cyan]Looking for season {season} (type: {type(season)}) in episodes_by_season keys: {sorted(episodes_by_season.keys())} (types: {[type(s) for s in episodes_by_season.keys()]})[/cyan]") + + # First try to find the episode in the specified season + if season in episodes_by_season: + if debug: + console.print(f"[green]Found season {season} in episodes_by_season[/green]") + + # Convert episode to int if not already + try: + episode = int(episode) + except (ValueError, TypeError): + if debug: + console.print(f"[yellow]Invalid episode number provided: {episode}, using as-is[/yellow]") + + max_episode_in_season = max([ep["episodeNumber"] or 0 for ep in episodes_by_season[season]]) + + if episode <= max_episode_in_season: + # Episode exists in this season normally + for ep in episodes_by_season[season]: + if ep["episodeNumber"] == episode: + found_episode = ep + if debug: + console.print(f"[green]Found episode S{season}E{episode} directly: {ep['name']}[/green]") + # Since we found it directly, return the original season and episode + return (season, episode, ep['id']) + else: + # Episode number is greater than max in this season, so try absolute numbering + if debug: + console.print(f"[yellow]Episode {episode} is greater than max episode ({max_episode_in_season}) in season {season}[/yellow]") + console.print("[yellow]Trying to find by absolute episode number...[/yellow]") + + # Calculate absolute episode number + absolute_number = episode + for s in range(1, season): + if s in episodes_by_season: + absolute_number += len(episodes_by_season[s]) + + if absolute_number in absolute_mapping: + actual_season = absolute_mapping[absolute_number]["season"] + actual_episode = absolute_mapping[absolute_number]["episode"] + + # Find the episode in the seasons data + for ep in episodes_by_season[actual_season]: + if ep["episodeNumber"] == actual_episode: + found_episode = ep + if debug: + console.print(f"[green]Found by absolute number {absolute_number}: S{actual_season}E{actual_episode} - {ep['name']}[/green]") + console.print(f"[bold yellow]Note: S{season}E{episode} maps to S{actual_season}E{actual_episode} using absolute numbering[/bold yellow]") + # Return the absolute-based season and episode since that's what corresponds to the actual content + return (actual_season, actual_episode, ep['id']) + else: + if debug: + console.print(f"[red]Could not find episode with absolute number {absolute_number}[/red]") + # Return original values if absolute mapping failed + return (season, episode, None) + else: + if debug: + console.print(f"[red]Season {season} not found in series[/red]") + # Return original values if season wasn't found + return (season, episode, None) + + # If we get here and haven't returned yet, return the original values + if not found_episode: + if debug: + console.print(f"[yellow]No matching episode found, keeping original S{season}E{episode}[/yellow]") + return (season, episode, None) + + # If we get here, no specific episode was requested or processing, so return the original values + return (season, episode, None) + + except httpx.HTTPStatusError as e: + console.print(f"[red]HTTP error occurred: {e.response.status_code} - {e.response.text}[/red]") + return (season, episode, None) + except httpx.RequestError as e: + console.print(f"[red]Request error occurred: {e}[/red]") + return (season, episode, None) + except Exception as e: + console.print(f"[red]Error fetching TVDb episode list: {str(e)}[/red]") + import traceback + console.print(f"[dim]{traceback.format_exc()}[/dim]") + return (season, episode, None) + + +async def get_tvdb_series_data(base_dir, token, tvdb_id, api_key=None, retry_attempted=False, debug=False): + if debug: + console.print(f"[cyan]Fetching TVDb series data for ID {tvdb_id}...[/cyan]") + url = f"https://api4.thetvdb.com/v4/series/{tvdb_id}" + headers = { + "accept": "application/json", + "Authorization": f"Bearer {token}" + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=30.0) + + if response.status_code == 401: + if api_key and not retry_attempted: + console.print("[yellow]Unauthorized access. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + return await get_tvdb_series_data( + base_dir, new_token, tvdb_id, api_key, True, debug + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + else: + console.print("[red]Unauthorized access to TVDb API[/red]") + return None + + response.raise_for_status() + data = response.json() + + if data.get("message") == "Unauthorized": + if api_key and not retry_attempted: + console.print("[yellow]Token invalid or expired. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + return await get_tvdb_series_data( + base_dir, new_token, tvdb_id, api_key, True, debug + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + else: + console.print("[red]Unauthorized response from TVDb API[/red]") + return None + + if data.get("status") == "success" and data.get("data"): + series_data = data["data"] + series_name = series_data.get("name") + if debug: + console.print(f"[bold cyan]TVDB series name: {series_name}[/bold cyan]") + return series_name + else: + if debug: + console.print(f"[yellow]No TVDb series data found for {tvdb_id}[/yellow]") + return None + + except httpx.HTTPStatusError as e: + console.print(f"[red]HTTP error occurred: {e.response.status_code} - {e.response.text}[/red]") + return None + except httpx.RequestError as e: + console.print(f"[red]Request error occurred: {e}[/red]") + return None + except Exception as e: + console.print(f"[red]Error fetching TVDb series data: {e}[/red]") + return None + + +async def get_tvdb_series(base_dir, title, year, apikey=None, token=None, debug=False): + if debug: + console.print(f"[cyan]Searching for TVDb series: {title} ({year})...[/cyan]") + + # Validate inputs + if not apikey: + console.print("[red]No TVDb API key provided[/red]") + return 0 + + if not token: + console.print("[red]No TVDb token provided[/red]") + return 0 + + if not title: + console.print("[red]No title provided for TVDb search[/red]") + return 0 + + async def search_tvdb(search_title, search_year=None, attempt_description=""): + """Helper function to perform the actual search""" + url = "https://api4.thetvdb.com/v4/search" + headers = { + "accept": "application/json", + "Authorization": f"Bearer {token}" + } + params = { + "query": search_title + } + + if search_year: + params["year"] = search_year + + if debug: + console.print(f"[cyan]{attempt_description}Searching with query: '{search_title}'{f' (year: {search_year})' if search_year else ''}[/cyan]") + + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, headers=headers, timeout=30.0) + + if response.status_code == 401: + console.print("[yellow]Unauthorized access. Token may be expired. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(apikey, base_dir) + if new_token: + headers["Authorization"] = f"Bearer {new_token}" + response = await client.get(url, params=params, headers=headers, timeout=30.0) + response.raise_for_status() + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + else: + response.raise_for_status() + + data = response.json() + + if data.get("message") == "Unauthorized": + console.print("[yellow]Token invalid or expired. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(apikey, base_dir) + if new_token: + headers["Authorization"] = f"Bearer {new_token}" + response = await client.get(url, params=params, headers=headers, timeout=30.0) + response.raise_for_status() + data = response.json() + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + + return data + + def names_match(series_name, search_title): + """Check if series name matches the search title (case-insensitive, basic cleanup)""" + if not series_name or not search_title: + return False + + series_clean = series_name.lower().strip() + title_clean = search_title.lower().strip() + if series_clean == title_clean: + return True + + series_cleaned = re.sub(r'[^\w\s]', '', series_clean) + title_cleaned = re.sub(r'[^\w\s]', '', title_clean) + + return series_cleaned == title_cleaned + + try: + # First attempt: Search with title and year (if year provided) + data = await search_tvdb(title, year, "Initial attempt: ") + + if data and data.get("status") == "success" and data.get("data"): + all_results = data["data"] + series_list = [item for item in all_results if item.get("type") == "series"] + + if debug: + console.print(f"[green]Found {len(all_results)} total results, {len(series_list)} series matches[/green]") + if series_list: + for i, series in enumerate(series_list[:3]): + name = series.get("name", "Unknown") + year_found = series.get("year", "Unknown") + tvdb_id = series.get("tvdb_id", "Unknown") + console.print(f"[cyan] {i+1}. {name} ({year_found}) - ID: {tvdb_id}[/cyan]") + + # Check if we found series and if the first result matches our title + if series_list: + first_series = series_list[0] + series_name = first_series.get("name", "") + + if names_match(series_name, title): + tvdb_id = first_series.get("tvdb_id") + series_year = first_series.get("year", "Unknown") + + if debug: + console.print(f"[green]Title match found: {series_name} ({series_year}) - ID: {tvdb_id}[/green]") + return tvdb_id + + elif year: + if debug: + console.print(f"[yellow]Series name '{series_name}' doesn't match title '{title}'. Retrying without year...[/yellow]") + + # Second attempt: Search without year + data2 = await search_tvdb(title, None, "Retry without year: ") + + if data2 and data2.get("status") == "success" and data2.get("data"): + all_results2 = data2["data"] + series_list2 = [item for item in all_results2 if item.get("type") == "series"] + + if debug: + console.print(f"[green]Retry found {len(all_results2)} total results, {len(series_list2)} series matches[/green]") + if series_list2: + for i, series in enumerate(series_list2[:3]): + name = series.get("name", "Unknown") + year_found = series.get("year", "Unknown") + tvdb_id = series.get("tvdb_id", "Unknown") + console.print(f"[cyan] {i+1}. {name} ({year_found}) - ID: {tvdb_id}[/cyan]") + + # Look for a better match in the new results + for series in series_list2: + series_name2 = series.get("name", "") + if names_match(series_name2, title): + tvdb_id = series.get("tvdb_id") + series_year = series.get("year", "Unknown") + + if debug: + console.print(f"[green]Better match found without year: {series_name2} ({series_year}) - ID: {tvdb_id}[/green]") + return tvdb_id + else: + if debug: + console.print(f"[yellow]No results found in retry without year for '{title}'[/yellow]") + return 0 + else: + if debug: + console.print(f"[yellow]Series name '{series_name}' doesn't match title '{title}' and no year provided. No further attempts will be made.[/yellow]") + return 0 + + else: + if debug: + console.print("[yellow]No series found in search results[/yellow]") + return 0 + else: + if debug: + console.print(f"[yellow]No TVDb results found for '{title}' ({year or 'no year'})[/yellow]") + if data and data.get("message"): + console.print(f"[yellow]API message: {data['message']}[/yellow]") + return 0 + + except httpx.HTTPStatusError as e: + if e.response.status_code == 401: + console.print("[red]Invalid API key or unauthorized access to TVDb[/red]") + elif e.response.status_code == 404: + console.print(f"[yellow]No results found for '{title}' ({year or 'no year'})[/yellow]") + elif e.response.status_code == 400: + console.print("[red]Bad request - check search parameters[/red]") + if debug: + console.print(f"[red]Response: {e.response.text}[/red]") + else: + console.print(f"[red]HTTP error occurred: {e.response.status_code} - {e.response.text}[/red]") + return 0 + except httpx.RequestError as e: + console.print(f"[red]Request error occurred: {e}[/red]") + return 0 + except Exception as e: + console.print(f"[red]Error searching TVDb series: {e}[/red]") + return 0 + + +async def get_tvdb_specific_episode_data(base_dir, token, tvdb_id, id, api_key=None, retry_attempted=False, debug=False): + if debug: + console.print(f"[cyan]Fetching specific episode data for ID {id} of series ID {tvdb_id}...[/cyan]") + + url = f"https://api4.thetvdb.com/v4/episodes/{id}/extended?meta=translations" + headers = { + "accept": "application/json", + "Authorization": f"Bearer {token}" + } + + try: + async with httpx.AsyncClient() as client: + response = await client.get(url, headers=headers, timeout=30.0) + + # Handle unauthorized responses + if response.status_code == 401: + # Only attempt a retry once to prevent infinite loops + if api_key and not retry_attempted: + console.print("[yellow]Unauthorized access. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + # Retry the request with the new token + return await get_tvdb_specific_episode_data( + base_dir, new_token, tvdb_id, id, api_key, True + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + else: + console.print("[red]Unauthorized access to TVDb API[/red]") + return None + + response.raise_for_status() + data = response.json() + + # Check for "Unauthorized" message in response body + if data.get("message") == "Unauthorized": + if api_key and not retry_attempted: + console.print("[yellow]Token invalid or expired. Refreshing TVDb token...[/yellow]") + new_token = await get_tvdb_token(api_key, base_dir) + if new_token: + return await get_tvdb_specific_episode_data( + base_dir, new_token, tvdb_id, id, api_key, True + ) + else: + console.print("[red]Failed to refresh TVDb token[/red]") + return None + else: + console.print("[red]Unauthorized response from TVDb API[/red]") + return None + if data.get("status") == "success" and data.get("data"): + episode_data = data["data"] + series_data = episode_data.get("seasons", {}) + season_number = episode_data.get("seasonNumber", 0) + episode_number = episode_data.get("number", 0) + season_name = "" + if isinstance(series_data, list) and series_data: + season_name = series_data[0].get("name", "") + episode_name = episode_data.get("name", "") + air_date = episode_data.get("aired", "") + overview = episode_data.get("overview", "") + year = episode_data.get("year", "") + + # Extract IMDB id from remoteIds + imdb_id = None + for rid in episode_data.get("remoteIds", []): + if rid.get("type") == 2 and rid.get("sourceName", "").upper() == "IMDB": + imdb_id = rid.get("id") + break + + # Extract English name from nameTranslations + eng_name = None + for nt in episode_data.get("translations", {}).get("nameTranslations", []): + if nt.get("language") == "eng": + eng_name = nt.get("name") + break + + # Extract English overview from overviewTranslations + eng_overview = None + for ot in episode_data.get("translations", {}).get("overviewTranslations", []): + if ot.get("language") == "eng": + eng_overview = ot.get("overview") + break + + result = { + "id": id, + "tvdb_id": tvdb_id, + "season_number": season_number, + "season_name": season_name, + "episode_number": episode_number, + "episode_name": episode_name, + "air_date": air_date, + "overview": overview, + "image_url": episode_data.get("image", ""), + "thumb_url": episode_data.get("thumbnail", ""), + "runtime": episode_data.get("runtime", 0), + "production_code": episode_data.get("productionCode", ""), + "finale_type": episode_data.get("finaleType", ""), + "year": year, + "imdb_id": imdb_id, + "eng_name": eng_name, + "eng_overview": eng_overview + } + + return result + + except httpx.HTTPError as e: + console.print(f"[red]HTTP error occurred while fetching TVDb episode data: {e}[/red]") + return None + except Exception as e: + console.print(f"[red]Error occurred while fetching TVDb episode data: {e}[/red]") + return None diff --git a/src/tvmaze.py b/src/tvmaze.py new file mode 100644 index 000000000..d06ce3b25 --- /dev/null +++ b/src/tvmaze.py @@ -0,0 +1,204 @@ +from src.console import console +import httpx +import json + + +async def search_tvmaze(filename, year, imdbID, tvdbID, manual_date=None, tvmaze_manual=None, debug=False, return_full_tuple=False): + """Searches TVMaze for a show using TVDB ID, IMDb ID, or a title query. + + - If `return_full_tuple=True`, returns `(tvmaze_id, imdbID, tvdbID)`. + - Otherwise, only returns `tvmaze_id`. + """ + if debug: + console.print(f"[cyan]Searching TVMaze for TVDB {tvdbID} or IMDB {imdbID} or {filename} ({year})[/cyan]") + # Convert TVDB ID to integer + try: + tvdbID = int(tvdbID) if tvdbID not in (None, '', '0') else 0 + except ValueError: + console.print(f"[red]Error: tvdbID is not a valid integer. Received: {tvdbID}[/red]") + tvdbID = 0 + + # Handle IMDb ID - ensure it's an integer without tt prefix + try: + if isinstance(imdbID, str) and imdbID.startswith('tt'): + imdbID = int(imdbID[2:]) + else: + imdbID = int(imdbID) if imdbID not in (None, '', '0') else 0 + except ValueError: + console.print(f"[red]Error: imdbID is not a valid integer. Received: {imdbID}[/red]") + imdbID = 0 + + # If manual selection has been provided, return it directly + if tvmaze_manual: + try: + tvmaze_id = int(tvmaze_manual) + return (tvmaze_id, imdbID, tvdbID) if return_full_tuple else tvmaze_id + except (ValueError, TypeError): + console.print(f"[red]Error: tvmaze_manual is not a valid integer. Received: {tvmaze_manual}[/red]") + tvmaze_id = 0 + return (tvmaze_id, imdbID, tvdbID) if return_full_tuple else tvmaze_id + + tvmaze_id = 0 + results = [] + + async def fetch_tvmaze_data(url, params): + """Helper function to fetch data from TVMaze API.""" + response = await _make_tvmaze_request(url, params) + if response: + return [response] if isinstance(response, dict) else response + return [] + + if tvdbID: + results.extend(await fetch_tvmaze_data("https://api.tvmaze.com/lookup/shows", {"thetvdb": tvdbID})) + + if not results and imdbID: + results.extend(await fetch_tvmaze_data("https://api.tvmaze.com/lookup/shows", {"imdb": f"tt{imdbID:07d}"})) + + if not results: + search_resp = await fetch_tvmaze_data("https://api.tvmaze.com/search/shows", {"q": filename}) + results.extend([each['show'] for each in search_resp if 'show' in each]) + + if not results: + first_two_words = " ".join(filename.split()[:2]) + if first_two_words and first_two_words != filename: + search_resp = await fetch_tvmaze_data("https://api.tvmaze.com/search/shows", {"q": first_two_words}) + results.extend([each['show'] for each in search_resp if 'show' in each]) + + # Deduplicate results by TVMaze ID + seen = set() + unique_results = [show for show in results if show['id'] not in seen and not seen.add(show['id'])] + + if not unique_results: + if debug: + console.print("[yellow]No TVMaze results found.[/yellow]") + return (tvmaze_id, imdbID, tvdbID) if return_full_tuple else tvmaze_id + + # Manual selection process + if manual_date is not None: + console.print("[bold]Search results:[/bold]") + for idx, show in enumerate(unique_results): + console.print(f"[bold red]{idx + 1}[/bold red]. [green]{show.get('name', 'Unknown')} (TVmaze ID:[/green] [bold red]{show['id']}[/bold red])") + console.print(f"[yellow] Premiered: {show.get('premiered', 'Unknown')}[/yellow]") + console.print(f" Externals: {json.dumps(show.get('externals', {}), indent=2)}") + + while True: + try: + choice = int(input(f"Enter the number of the correct show (1-{len(unique_results)}) or 0 to skip: ")) + if choice == 0: + console.print("Skipping selection.") + break + if 1 <= choice <= len(unique_results): + selected_show = unique_results[choice - 1] + tvmaze_id = int(selected_show['id']) + # set the tvdb id since it's sure to be correct + # won't get returned outside manual date since full tuple is not returned + if 'externals' in selected_show and 'thetvdb' in selected_show['externals']: + new_tvdb_id = selected_show['externals']['thetvdb'] + if new_tvdb_id: + tvdbID = int(new_tvdb_id) + console.print(f"[green]Updated TVDb ID to: {tvdbID}[/green]") + console.print(f"Selected show: {selected_show.get('name')} (TVmaze ID: {tvmaze_id})") + break + else: + console.print(f"Invalid choice. Please choose a number between 1 and {len(unique_results)}, or 0 to skip.") + except ValueError: + console.print("Invalid input. Please enter a number.") + else: + selected_show = unique_results[0] + tvmaze_id = int(selected_show['id']) + if debug: + console.print(f"[cyan]Automatically selected show: {selected_show.get('name')} (TVmaze ID: {tvmaze_id})[/cyan]") + + if 'externals' in selected_show: + if 'thetvdb' in selected_show['externals'] and not tvdbID: + tvdbID = selected_show['externals']['thetvdb'] + if tvdbID: + tvdbID = int(tvdbID) + return_full_tuple = True + if debug: + console.print(f"[cyan]Returning TVmaze ID: {tvmaze_id} (type: {type(tvmaze_id).__name__}), IMDb ID: {imdbID} (type: {type(imdbID).__name__}), TVDB ID: {tvdbID} (type: {type(tvdbID).__name__})[/cyan]") + if tvmaze_id is None: + tvmaze_id = 0 + if imdbID is None: + imdbID = 0 + if tvdbID is None: + tvdbID = 0 + + return (tvmaze_id, imdbID, tvdbID) if return_full_tuple else tvmaze_id + + +async def _make_tvmaze_request(url, params): + """Sync function to make the request inside ThreadPoolExecutor.""" + try: + async with httpx.AsyncClient(follow_redirects=True) as client: + resp = await client.get(url, params=params, timeout=10) + if resp.status_code == 200: + return resp.json() + else: + return None + except httpx.HTTPStatusError as e: + print(f"[ERROR] TVmaze API error: {e.response.status_code}") + except httpx.RequestError as e: + print(f"[ERROR] Network error while accessing TVmaze: {e}") + return {} + + +async def get_tvmaze_episode_data(tvmaze_id, season, episode): + url = f"https://api.tvmaze.com/shows/{tvmaze_id}/episodebynumber" + params = { + "season": season, + "number": episode + } + + try: + async with httpx.AsyncClient(follow_redirects=True) as client: + response = await client.get(url, params=params, timeout=10.0) + response.raise_for_status() + data = response.json() + + if data: + # Get show data for additional information + show_data = {} + if "show" in data.get("_links", {}) and "href" in data["_links"]["show"]: + show_url = data["_links"]["show"]["href"] + show_name = data["_links"]["show"].get("name", "") + + show_response = await client.get(show_url, timeout=10.0) + if show_response.status_code == 200: + show_data = show_response.json() + else: + show_data = {"name": show_name} + + # Clean HTML tags from summary + summary = data.get("summary", "") + if summary: + summary = summary.replace("

", "").replace("

", "").strip() + + # Format the response in a consistent structure + result = { + "episode_name": data.get("name", ""), + "overview": summary, + "season_number": data.get("season", season), + "episode_number": data.get("number", episode), + "air_date": data.get("airdate", ""), + "runtime": data.get("runtime", 0), + "series_name": show_data.get("name", data.get("_links", {}).get("show", {}).get("name", "")), + "series_overview": show_data.get("summary", "").replace("

", "").replace("

", "").strip(), + "image": data.get("image", {}).get("original", None) if data.get("image") else None, + "series_image": show_data.get("image", {}).get("original", None) if show_data.get("image") else None, + } + + return result + else: + console.print(f"[yellow]No episode data found for S{season:02d}E{episode:02d}[/yellow]") + return None + + except httpx.HTTPStatusError as e: + console.print(f"[red]HTTP error occurred: {e.response.status_code} - {e.response.text}[/red]") + return None + except httpx.RequestError as e: + console.print(f"[red]Request error occurred: {e}[/red]") + return None + except Exception as e: + console.print(f"[red]Error fetching TVMaze episode data: {e}[/red]") + return None diff --git a/src/uphelper.py b/src/uphelper.py new file mode 100644 index 000000000..6e3dfd7ad --- /dev/null +++ b/src/uphelper.py @@ -0,0 +1,291 @@ +import cli_ui +import os +import json +import sys + +from data.config import config +from src.cleanup import cleanup, reset_terminal +from src.console import console + + +class UploadHelper: + async def dupe_check(self, dupes, meta, tracker_name): + if not dupes: + if meta['debug']: + console.print(f"[green]No dupes found at[/green] [yellow]{tracker_name}[/yellow]") + meta['upload'] = True + return meta, False + else: + if meta.get('trumpable', False): + trumpable_dupes = [d for d in dupes if isinstance(d, dict) and d.get('trumpable')] + if trumpable_dupes: + trumpable_text = "\n".join([ + f"{d['name']} - {d['link']}" if 'link' in d else d['name'] + for d in trumpable_dupes + ]) + console.print("[bold red]Trumpable found![/bold red]") + console.print(f"[bold cyan]{trumpable_text}[/bold cyan]") + # Remove trumpable dupes from the main list + dupes = [d for d in dupes if not (isinstance(d, dict) and d.get('trumpable'))] + if (not meta['unattended'] or (meta['unattended'] and meta.get('unattended_confirm', False))) and not meta.get('ask_dupe', False): + dupe_text = "\n".join([ + f"{d['name']} - {d['link']}" if isinstance(d, dict) and 'link' in d and d['link'] is not None else (d['name'] if isinstance(d, dict) else d) + for d in dupes + ]) + if not dupe_text and meta.get('trumpable', False): + console.print("[yellow]Please check the trumpable entries above to see if you want to upload, and report the trumpable torrent if you upload.[/yellow]") + if meta.get('dupe', False) is False: + try: + upload = cli_ui.ask_yes_no(f"Upload to {tracker_name} anyway?", default=False) + meta['we_asked'] = True + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + else: + upload = True + meta['we_asked'] = False + else: + if meta.get('filename_match', False) and meta.get('file_count_match', False): + console.print(f'[bold red]Exact filename matches found! - {meta["filename_match"]}[/bold red]') + try: + upload = cli_ui.ask_yes_no(f"Upload to {tracker_name} anyway?", default=False) + meta['we_asked'] = True + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + else: + console.print(f"[bold blue]Check if these are actually dupes from {tracker_name}:[/bold blue]") + console.print() + console.print(f"[bold cyan]{dupe_text}[/bold cyan]") + if meta.get('dupe', False) is False: + try: + upload = cli_ui.ask_yes_no(f"Upload to {tracker_name} anyway?", default=False) + meta['we_asked'] = True + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + else: + upload = True + meta['we_asked'] = False + else: + if meta.get('dupe', False) is False: + upload = False + meta['we_asked'] = True + else: + upload = True + + if upload is False: + return meta, True + else: + for each in dupes: + each_name = each['name'] if isinstance(each, dict) else each + if each_name == meta['name']: + meta['name'] = f"{meta['name']} DUPE?" + + return meta, False + + async def get_confirmation(self, meta): + if meta['debug'] is True: + console.print("[bold red]DEBUG: True - Will not actually upload!") + console.print(f"Prep material saved to {meta['base_dir']}/tmp/{meta['uuid']}") + console.print() + console.print("[bold yellow]Database Info[/bold yellow]") + console.print(f"[bold]Title:[/bold] {meta['title']} ({meta['year']})") + console.print() + if not meta.get('emby', False): + console.print(f"[bold]Overview:[/bold] {meta['overview'][:100]}....") + console.print() + if meta.get('category') == 'TV' and not meta.get('tv_pack') and meta.get('auto_episode_title'): + console.print(f"[bold]Episode Title:[/bold] {meta['auto_episode_title']}") + console.print() + if meta.get('category') == 'TV' and not meta.get('tv_pack') and meta.get('overview_meta'): + console.print(f"[bold]Episode overview:[/bold] {meta['overview_meta']}") + console.print() + console.print(f"[bold]Genre:[/bold] {meta['genres']}") + console.print() + if str(meta.get('demographic', '')) != '': + console.print(f"[bold]Demographic:[/bold] {meta['demographic']}") + console.print() + console.print(f"[bold]Category:[/bold] {meta['category']}") + console.print() + if meta.get('emby_debug', False): + if int(meta.get('original_imdb', 0)) != 0: + imdb = str(meta.get('original_imdb', 0)).zfill(7) + console.print(f"[bold]IMDB:[/bold] https://www.imdb.com/title/tt{imdb}") + if int(meta.get('original_tmdb', 0)) != 0: + console.print(f"[bold]TMDB:[/bold] https://www.themoviedb.org/{meta['category'].lower()}/{meta['original_tmdb']}") + if int(meta.get('original_tvdb', 0)) != 0: + console.print(f"[bold]TVDB:[/bold] https://www.thetvdb.com/?id={meta['original_tvdb']}&tab=series") + if int(meta.get('original_tvmaze', 0)) != 0: + console.print(f"[bold]TVMaze:[/bold] https://www.tvmaze.com/shows/{meta['original_tvmaze']}") + if int(meta.get('original_mal', 0)) != 0: + console.print(f"[bold]MAL:[/bold] https://myanimelist.net/anime/{meta['original_mal']}") + else: + if int(meta.get('tmdb_id') or 0) != 0: + console.print(f"[bold]TMDB:[/bold] https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb_id']}") + if int(meta.get('imdb_id') or 0) != 0: + console.print(f"[bold]IMDB:[/bold] https://www.imdb.com/title/tt{meta['imdb']}") + if int(meta.get('tvdb_id') or 0) != 0: + console.print(f"[bold]TVDB:[/bold] https://www.thetvdb.com/?id={meta['tvdb_id']}&tab=series") + if int(meta.get('tvmaze_id') or 0) != 0: + console.print(f"[bold]TVMaze:[/bold] https://www.tvmaze.com/shows/{meta['tvmaze_id']}") + if int(meta.get('mal_id') or 0) != 0: + console.print(f"[bold]MAL:[/bold] https://myanimelist.net/anime/{meta['mal_id']}") + console.print() + if not meta.get('emby', False): + if int(meta.get('freeleech', 0)) != 0: + console.print(f"[bold]Freeleech:[/bold] {meta['freeleech']}") + tag = "" if meta['tag'] == "" else f" / {meta['tag'][1:]}" + res = meta['source'] if meta['is_disc'] == "DVD" else meta['resolution'] + console.print(f"{res} / {meta['type']}{tag}") + if meta.get('personalrelease', False) is True: + console.print("[bold green]Personal Release![/bold green]") + console.print() + + if meta.get('unattended', False) and not meta.get('unattended_confirm', False) and not meta.get('emby_debug', False): + if meta['debug'] is True: + console.print("[bold yellow]Unattended mode is enabled, skipping confirmation.[/bold yellow]") + return True + else: + if not meta.get('emby', False): + await self.get_missing(meta) + ring_the_bell = "\a" if config['DEFAULT'].get("sfx_on_prompt", True) is True else "" + if ring_the_bell: + console.print(ring_the_bell) + + if meta.get('is disc', False) is True: + meta['keep_folder'] = False + + if meta.get('keep_folder') and meta['isdir']: + console.print("[bold yellow]Uploading with --keep-folder[/bold yellow]") + kf_confirm = console.input("[bold yellow]You specified --keep-folder. Uploading in folders might not be allowed.[/bold yellow] [green]Proceed? y/N: [/green]").strip().lower() + if kf_confirm != 'y': + console.print("[bold red]Aborting...[/bold red]") + exit() + + if not meta.get('emby', False): + console.print(f"[bold]Name:[/bold] {meta['name']}") + confirm = console.input("[bold green]Is this correct?[/bold green] [yellow]y/N[/yellow]: ").strip().lower() == 'y' + elif not meta.get('emby_debug', False): + confirm = console.input("[bold green]Is this correct?[/bold green] [yellow]y/N[/yellow]: ").strip().lower() == 'y' + if meta.get('emby_debug', False): + if meta.get('original_imdb', 0) != meta.get('imdb_id', 0): + imdb = str(meta.get('imdb_id', 0)).zfill(7) + console.print(f"[bold red]IMDB ID changed from {meta['original_imdb']} to {meta['imdb_id']}[/bold red]") + console.print(f"[bold cyan]IMDB URL:[/bold cyan] [yellow]https://www.imdb.com/title/tt{imdb}[/yellow]") + if meta.get('original_tmdb', 0) != meta.get('tmdb_id', 0): + console.print(f"[bold red]TMDB ID changed from {meta['original_tmdb']} to {meta['tmdb_id']}[/bold red]") + console.print(f"[bold cyan]TMDB URL:[/bold cyan] [yellow]https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb_id']}[/yellow]") + if meta.get('original_mal', 0) != meta.get('mal_id', 0): + console.print(f"[bold red]MAL ID changed from {meta['original_mal']} to {meta['mal_id']}[/bold red]") + console.print(f"[bold cyan]MAL URL:[/bold cyan] [yellow]https://myanimelist.net/anime/{meta['mal_id']}[/yellow]") + if meta.get('original_tvmaze', 0) != meta.get('tvmaze_id', 0): + console.print(f"[bold red]TVMaze ID changed from {meta['original_tvmaze']} to {meta['tvmaze_id']}[/bold red]") + console.print(f"[bold cyan]TVMaze URL:[/bold cyan] [yellow]https://www.tvmaze.com/shows/{meta['tvmaze_id']}[/yellow]") + if meta.get('original_tvdb', 0) != meta.get('tvdb_id', 0): + console.print(f"[bold red]TVDB ID changed from {meta['original_tvdb']} to {meta['tvdb_id']}[/bold red]") + console.print(f"[bold cyan]TVDB URL:[/bold cyan] [yellow]https://www.thetvdb.com/?id={meta['tvdb_id']}&tab=series[/yellow]") + if meta.get('original_category', None) != meta.get('category', None): + console.print(f"[bold red]Category changed from {meta['original_category']} to {meta['category']}[/bold red]") + console.print(f"[bold cyan]Regex Title:[/bold cyan] [yellow]{meta.get('regex_title', 'N/A')}[/yellow], [bold cyan]Secondary Title:[/bold cyan] [yellow]{meta.get('regex_secondary_title', 'N/A')}[/yellow], [bold cyan]Year:[/bold cyan] [yellow]{meta.get('regex_year', 'N/A')}, [bold cyan]AKA:[/bold cyan] [yellow]{meta.get('aka', '')}[/yellow]") + console.print() + if meta.get('original_imdb', 0) == meta.get('imdb_id', 0) and meta.get('original_tmdb', 0) == meta.get('tmdb_id', 0) and meta.get('original_mal', 0) == meta.get('mal_id', 0) and meta.get('original_tvmaze', 0) == meta.get('tvmaze_id', 0) and meta.get('original_tvdb', 0) == meta.get('tvdb_id', 0) and meta.get('original_category', None) == meta.get('category', None): + console.print("[bold yellow]Database ID's are correct![/bold yellow]") + return True + else: + nfo_dir = os.path.join(f"{meta['base_dir']}/data") + os.makedirs(nfo_dir, exist_ok=True) + json_file_path = os.path.join(nfo_dir, "db_check.json") + + def imdb_url(imdb_id): + return f"https://www.imdb.com/title/tt{str(imdb_id).zfill(7)}" if imdb_id and str(imdb_id).isdigit() else None + + def tmdb_url(tmdb_id, category): + return f"https://www.themoviedb.org/{str(category).lower()}/{tmdb_id}" if tmdb_id and category else None + + def tvdb_url(tvdb_id): + return f"https://www.thetvdb.com/?id={tvdb_id}&tab=series" if tvdb_id else None + + def tvmaze_url(tvmaze_id): + return f"https://www.tvmaze.com/shows/{tvmaze_id}" if tvmaze_id else None + + def mal_url(mal_id): + return f"https://myanimelist.net/anime/{mal_id}" if mal_id else None + + db_check_entry = { + "path": meta.get('path'), + "original": { + "imdb_id": meta.get('original_imdb', 'N/A'), + "imdb_url": imdb_url(meta.get('original_imdb')), + "tmdb_id": meta.get('original_tmdb', 'N/A'), + "tmdb_url": tmdb_url(meta.get('original_tmdb'), meta.get('original_category')), + "tvdb_id": meta.get('original_tvdb', 'N/A'), + "tvdb_url": tvdb_url(meta.get('original_tvdb')), + "tvmaze_id": meta.get('original_tvmaze', 'N/A'), + "tvmaze_url": tvmaze_url(meta.get('original_tvmaze')), + "mal_id": meta.get('original_mal', 'N/A'), + "mal_url": mal_url(meta.get('original_mal')), + "category": meta.get('original_category', 'N/A') + }, + "changed": { + "imdb_id": meta.get('imdb_id', 'N/A'), + "imdb_url": imdb_url(meta.get('imdb_id')), + "tmdb_id": meta.get('tmdb_id', 'N/A'), + "tmdb_url": tmdb_url(meta.get('tmdb_id'), meta.get('category')), + "tvdb_id": meta.get('tvdb_id', 'N/A'), + "tvdb_url": tvdb_url(meta.get('tvdb_id')), + "tvmaze_id": meta.get('tvmaze_id', 'N/A'), + "tvmaze_url": tvmaze_url(meta.get('tvmaze_id')), + "mal_id": meta.get('mal_id', 'N/A'), + "mal_url": mal_url(meta.get('mal_id')), + "category": meta.get('category', 'N/A') + }, + "tracker": meta.get('matched_tracker', 'N/A'), + } + + # Append to JSON file (as a list of entries) + if os.path.exists(json_file_path): + with open(json_file_path, 'r', encoding='utf-8') as f: + try: + db_data = json.load(f) + if not isinstance(db_data, list): + db_data = [] + except Exception: + db_data = [] + else: + db_data = [] + + db_data.append(db_check_entry) + + with open(json_file_path, 'w', encoding='utf-8') as f: + json.dump(db_data, f, indent=2, ensure_ascii=False) + return True + + return confirm + + async def get_missing(self, meta): + info_notes = { + 'edition': 'Special Edition/Release', + 'description': "Please include Remux/Encode Notes if possible", + 'service': "WEB Service e.g.(AMZN, NF)", + 'region': "Disc Region", + 'imdb': 'IMDb ID (tt1234567)', + 'distributor': "Disc Distributor e.g.(BFI, Criterion)" + } + missing = [] + if meta.get('imdb_id', 0) == 0: + meta['imdb_id'] = 0 + meta['potential_missing'].append('imdb_id') + for each in meta['potential_missing']: + if str(meta.get(each, '')).strip() in ["", "None", "0"]: + missing.append(f"--{each} | {info_notes.get(each, '')}") + if missing: + console.print("[bold yellow]Potentially missing information:[/bold yellow]") + for each in missing: + cli_ui.info(each) diff --git a/src/uploadscreens.py b/src/uploadscreens.py new file mode 100644 index 000000000..26625c124 --- /dev/null +++ b/src/uploadscreens.py @@ -0,0 +1,708 @@ +from src.console import console +import os +import pyimgbox +import asyncio +import requests +import glob +import base64 +import time +import re +import gc +import json +from concurrent.futures import ThreadPoolExecutor +import traceback +import httpx +import aiofiles + +try: + from data.config import config +except Exception: + print("[red]Error: Unable to import config. Ensure the config file is in the correct location.[/red]") + print("[red]Follow the setup instructions: https://github.com/Audionut/Upload-Assistant") + traceback.print_exc() + exit(1) + + +async def upload_image_task(args): + image, img_host, config, meta = args + try: + timeout = 60 # Default timeout + img_url, raw_url, web_url = None, None, None + + if img_host == "imgbox": + try: + image_list = await imgbox_upload(os.getcwd(), [image], meta, return_dict={}) + if image_list and all( + 'img_url' in img and 'raw_url' in img and 'web_url' in img for img in image_list + ): + img_url = image_list[0]['img_url'] + raw_url = image_list[0]['raw_url'] + web_url = image_list[0]['web_url'] + else: + return { + 'status': 'failed', + 'reason': "Imgbox upload failed. No valid URLs returned." + } + except Exception as e: + return { + 'status': 'failed', + 'reason': f"Error during Imgbox upload: {str(e)}" + } + + elif img_host == "ptpimg": + try: + payload = { + 'format': 'json', + 'api_key': config['DEFAULT']['ptpimg_api'].strip() + } + except KeyError: + return {'status': 'failed', 'reason': 'Missing ptpimg API key in config'} + + try: + async with httpx.AsyncClient() as client: + async with aiofiles.open(image, 'rb') as file: + files = {'file-upload[0]': (os.path.basename(image), await file.read())} + headers = {'referer': 'https://ptpimg.me/index.php'} + if meta.get('debug'): + console.print(f"[cyan][ptpimg] Headers: {headers}[/cyan]") + console.print(f"[cyan][ptpimg] Files: {list(files.keys())}[/cyan]") + + try: + response = await client.post( + "https://ptpimg.me/upload.php", + headers=headers, + data=payload, + files=files, + timeout=timeout + ) + if meta.get('debug'): + console.print(f"[cyan][ptpimg] Response status: {response.status_code}[/cyan]") + console.print(f"[cyan][ptpimg] Response text: {response.text[:500]}[/cyan]") + + response.raise_for_status() + response_data = response.json() + if meta.get('debug'): + console.print(f"[cyan][ptpimg] Response JSON: {response_data}[/cyan]") + + if not response_data or not isinstance(response_data, list) or 'code' not in response_data[0]: + return {'status': 'failed', 'reason': "Invalid JSON response from ptpimg"} + + code = response_data[0]['code'] + ext = response_data[0]['ext'] + if meta.get('debug'): + console.print(f"[cyan][ptpimg] Image code: {code}, extension: {ext}[/cyan]") + img_url = f"https://ptpimg.me/{code}.{ext}" + raw_url = img_url + web_url = img_url + + except httpx.TimeoutException: + console.print("[red][ptpimg] Request timed out.") + return {'status': 'failed', 'reason': 'Request timed out'} + except ValueError as e: + console.print(f"[red][ptpimg] ValueError: {str(e)}") + return {'status': 'failed', 'reason': f"Request failed: {str(e)}"} + except json.JSONDecodeError as e: + console.print(f"[red][ptpimg] JSONDecodeError: {str(e)}") + return {'status': 'failed', 'reason': 'Invalid JSON response from ptpimg'} + except Exception as e: + console.print(f"[red][ptpimg] Exception: {str(e)}") + return {'status': 'failed', 'reason': f"Error during ptpimg upload: {str(e)}"} + + elif img_host == "imgbb": + url = "https://api.imgbb.com/1/upload" + try: + async with aiofiles.open(image, "rb") as img_file: + encoded_image = base64.b64encode(await img_file.read()).decode('utf8') + + data = { + 'key': config['DEFAULT']['imgbb_api'], + 'image': encoded_image, + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, data=data, timeout=timeout) + response_data = response.json() + if response.status_code != 200 or not response_data.get('success'): + console.print("[yellow]imgbb failed, trying next image host") + return {'status': 'failed', 'reason': 'imgbb upload failed'} + + img_url = response_data['data'].get('medium', {}).get('url') or response_data['data']['thumb']['url'] + raw_url = response_data['data']['image']['url'] + web_url = response_data['data']['url_viewer'] + + if meta['debug']: + console.print(f"[green]Image URLs: img_url={img_url}, raw_url={raw_url}, web_url={web_url}") + + return {'status': 'success', 'img_url': img_url, 'raw_url': raw_url, 'web_url': web_url} + + except httpx.TimeoutException: + console.print("[red]Request timed out. The server took too long to respond.") + return {'status': 'failed', 'reason': 'Request timed out'} + + except ValueError as e: # JSON decoding error + console.print(f"[red]Invalid JSON response: {e}") + return {'status': 'failed', 'reason': 'Invalid JSON response'} + + except httpx.RequestError as e: + console.print(f"[red]Request failed with error: {e}") + return {'status': 'failed', 'reason': str(e)} + + elif img_host == "dalexni": + url = "https://dalexni.com/1/upload" + try: + with open(image, "rb") as img_file: + encoded_image = base64.b64encode(img_file.read()).decode('utf8') + + data = { + 'key': config['DEFAULT']['dalexni_api'], + 'image': encoded_image, + } + + response = requests.post(url, data=data, timeout=timeout) + response_data = response.json() + if response.status_code != 200 or not response_data.get('success'): + console.print("[yellow]DALEXNI failed, trying next image host") + return {'status': 'failed', 'reason': 'DALEXNI upload failed'} + + img_url = response_data['data'].get('medium', {}).get('url') or response_data['data']['thumb']['url'] + raw_url = response_data['data']['image']['url'] + web_url = response_data['data']['url_viewer'] + + if meta['debug']: + console.print(f"[green]Image URLs: img_url={img_url}, raw_url={raw_url}, web_url={web_url}") + + return {'status': 'success', 'img_url': img_url, 'raw_url': raw_url, 'web_url': web_url} + + except requests.exceptions.Timeout: + console.print("[red]Request timed out. The server took too long to respond.") + return {'status': 'failed', 'reason': 'Request timed out'} + + except ValueError as e: # JSON decoding error + console.print(f"[red]Invalid JSON response: {e}") + return {'status': 'failed', 'reason': 'Invalid JSON response'} + + except requests.exceptions.RequestException as e: + console.print(f"[red]Request failed with error: {e}") + return {'status': 'failed', 'reason': str(e)} + + elif img_host == "ptscreens": + url = "https://ptscreens.com/api/1/upload" + try: + headers = { + 'X-API-Key': config['DEFAULT']['ptscreens_api'] + } + + async with httpx.AsyncClient() as client: + async with aiofiles.open(image, 'rb') as file: + files = { + 'source': ('file-upload[0]', await file.read()) + } + + response = await client.post(url, headers=headers, files=files, timeout=timeout) + response_data = response.json() + + if response.status_code == 400: + console.print("[yellow]ptscreens upload failed: Duplicate upload (400)") + return {'status': 'failed', 'reason': 'ptscreens duplicate'} + + if response_data.get('status_code') != 200: + console.print("[yellow]ptscreens failed") + return {'status': 'failed', 'reason': 'ptscreens upload failed'} + + img_url = response_data['image']['medium']['url'] + raw_url = response_data['image']['url'] + web_url = response_data['image']['url_viewer'] + + if meta['debug']: + console.print(f"[green]Image URLs: img_url={img_url}, raw_url={raw_url}, web_url={web_url}") + + except httpx.TimeoutException: + console.print("[red]Request timed out. The server took too long to respond.") + return {'status': 'failed', 'reason': 'Request timed out'} + except httpx.RequestError as e: + console.print(f"[red]Request failed with error: {e}") + return {'status': 'failed', 'reason': str(e)} + except ValueError as e: + console.print(f"[red]Invalid JSON response from ptscreens: {e}") + return {'status': 'failed', 'reason': 'Invalid JSON response'} + + elif img_host == "onlyimage": + url = "https://onlyimage.org/api/1/upload" + try: + async with aiofiles.open(image, "rb") as img_file: + encoded_image = base64.b64encode(await img_file.read()).decode('utf8') + + data = { + 'image': encoded_image + } + headers = { + 'X-API-Key': config['DEFAULT']['onlyimage_api'], + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, data=data, headers=headers, timeout=timeout) + response_data = response.json() + + if response.status_code != 200 or not response_data.get('success'): + console.print("[yellow]OnlyImage failed, trying next image host") + return {'status': 'failed', 'reason': 'OnlyImage upload failed'} + + img_url = response_data['data']['medium']['url'] + raw_url = response_data['data']['image']['url'] + web_url = response_data['data']['url_viewer'] + + if meta['debug']: + console.print(f"[green]Image URLs: img_url={img_url}, raw_url={raw_url}, web_url={web_url}") + + except httpx.TimeoutException: + console.print("[red]Request timed out. The server took too long to respond.") + return {'status': 'failed', 'reason': 'Request timed out'} + except httpx.RequestError as e: + console.print(f"[red]Request failed with error: {e}") + return {'status': 'failed', 'reason': str(e)} + except ValueError as e: + console.print(f"[red]Invalid JSON response from OnlyImage: {e}") + return {'status': 'failed', 'reason': 'Invalid JSON response'} + + elif img_host == "pixhost": + url = "https://api.pixhost.to/images" + try: + data = { + 'content_type': '0', + 'max_th_size': 350 + } + + async with httpx.AsyncClient() as client: + async with aiofiles.open(image, 'rb') as file: + files = { + 'img': ('file-upload[0]', await file.read()) + } + + response = await client.post(url, data=data, files=files, timeout=timeout) + + if response.status_code != 200: + console.print(f"[yellow]pixhost failed with status code {response.status_code}, trying next image host") + return {'status': 'failed', 'reason': f'pixhost upload failed with status code {response.status_code}'} + + try: + response_data = response.json() + if 'th_url' not in response_data: + console.print("[yellow]pixhost failed: Invalid response format") + return {'status': 'failed', 'reason': 'Invalid response from pixhost'} + + raw_url = response_data['th_url'].replace('https://t', 'https://img').replace('/thumbs/', '/images/') + img_url = response_data['th_url'] + web_url = response_data['show_url'] + + if meta['debug']: + console.print(f"[green]Image URLs: img_url={img_url}, raw_url={raw_url}, web_url={web_url}") + + except ValueError as e: + console.print(f"[red]Invalid JSON response from pixhost: {e}") + return {'status': 'failed', 'reason': 'Invalid JSON response'} + + except httpx.TimeoutException: + console.print("[red]Request to pixhost timed out. The server took too long to respond.") + return {'status': 'failed', 'reason': 'Request timed out'} + + except httpx.RequestError as e: + console.print(f"[red]pixhost request failed with error: {e}") + return {'status': 'failed', 'reason': str(e)} + + elif img_host == "lensdump": + url = "https://lensdump.com/api/1/upload" + data = { + 'image': base64.b64encode(open(image, "rb").read()).decode('utf8') + } + headers = { + 'X-API-Key': config['DEFAULT']['lensdump_api'] + } + response = requests.post(url, data=data, headers=headers, timeout=timeout) + response_data = response.json() + if response_data.get('status_code') == 200: + img_url = response_data['data']['image']['url'] + raw_url = response_data['data']['image']['url'] + web_url = response_data['data']['url_viewer'] + + elif img_host == "zipline": + url = config['DEFAULT'].get('zipline_url') + api_key = config['DEFAULT'].get('zipline_api_key') + + if not url or not api_key: + console.print("[red]Error: Missing Zipline URL or API key in config.") + return {'status': 'failed', 'reason': 'Missing Zipline URL or API key'} + + try: + with open(image, "rb") as img_file: + files = {'file': img_file} + headers = { + 'Authorization': f'{api_key}', + } + + response = requests.post(url, files=files, headers=headers, timeout=timeout) + if response.status_code == 200: + response_data = response.json() + if 'files' in response_data: + img_url = response_data['files'][0] + raw_url = img_url.replace('/u/', '/r/') + web_url = img_url.replace('/u/', '/r/') + return { + 'status': 'success', + 'img_url': img_url, + 'raw_url': raw_url, + 'web_url': web_url + } + else: + return {'status': 'failed', 'reason': 'No valid URL returned from Zipline'} + + else: + return {'status': 'failed', 'reason': f"Zipline upload failed: {response.text}"} + except requests.exceptions.Timeout: + console.print("[red]Request timed out. The server took too long to respond.") + return {'status': 'failed', 'reason': 'Request timed out'} + + except ValueError as e: # JSON decoding error + console.print(f"[red]Invalid JSON response: {e}") + return {'status': 'failed', 'reason': 'Invalid JSON response'} + + except requests.exceptions.RequestException as e: + console.print(f"[red]Request failed with error: {e}") + return {'status': 'failed', 'reason': str(e)} + + elif img_host == "passtheimage": + url = "https://passtheima.ge/api/1/upload" + try: + pass_api_key = config['DEFAULT'].get('passtheima_ge_api') + if not pass_api_key: + console.print("[red]Passtheimage API key not found in config.") + return {'status': 'failed', 'reason': 'Missing Passtheimage API key'} + + headers = { + 'X-API-Key': pass_api_key + } + + async with httpx.AsyncClient() as client: + async with aiofiles.open(image, 'rb') as img_file: + files = {'source': (os.path.basename(image), await img_file.read())} + response = await client.post(url, headers=headers, files=files, timeout=timeout) + + if 'application/json' in response.headers.get('Content-Type', ''): + response_data = response.json() + else: + console.print(f"[red]Passtheimage did not return JSON. Status: {response.status_code}, Response: {response.text[:200]}") + return {'status': 'failed', 'reason': f'Non-JSON response from passtheimage: {response.status_code}'} + + if response.status_code != 200 or response_data.get('status_code') != 200: + error_message = response_data.get('error', {}).get('message', 'Unknown error') + error_code = response_data.get('error', {}).get('code', 'Unknown code') + console.print(f"[yellow]Passtheimage failed (code: {error_code}): {error_message}") + return {'status': 'failed', 'reason': f'passtheimage upload failed: {error_message}'} + + if 'image' in response_data: + img_url = response_data['image']['url'] + raw_url = response_data['image']['url'] + web_url = response_data['image']['url_viewer'] + + if not img_url or not raw_url or not web_url: + console.print(f"[yellow]Incomplete URL data from passtheimage response: {response_data}") + return {'status': 'failed', 'reason': 'Incomplete URL data from passtheimage'} + + return {'status': 'success', 'img_url': img_url, 'raw_url': raw_url, 'web_url': web_url, 'local_file_path': image} + + except httpx.TimeoutException: + console.print("[red]Request to passtheimage timed out after 60 seconds") + return {'status': 'failed', 'reason': 'Request timed out'} + except httpx.RequestError as e: + console.print(f"[red]Request to passtheimage failed with error: {e}") + return {'status': 'failed', 'reason': str(e)} + except ValueError as e: + console.print(f"[red]Invalid JSON response from passtheimage: {e}") + return {'status': 'failed', 'reason': 'Invalid JSON response'} + except Exception as e: + console.print(f"[red]Unexpected error with passtheimage: {str(e)}") + return {'status': 'failed', 'reason': f'Unexpected error: {str(e)}'} + + if img_url and raw_url and web_url: + return { + 'status': 'success', + 'img_url': img_url, + 'raw_url': raw_url, + 'web_url': web_url, + 'local_file_path': image + } + else: + return { + 'status': 'failed', + 'reason': f"Failed to upload image to {img_host}. No URLs received." + } + + except Exception as e: + return { + 'status': 'failed', + 'reason': str(e) + } + + +# Global Thread Pool Executor for better thread control +thread_pool = ThreadPoolExecutor(max_workers=10) + + +async def upload_screens(meta, screens, img_host_num, i, total_screens, custom_img_list, return_dict, retry_mode=False, max_retries=3): + if 'image_list' not in meta: + meta['image_list'] = [] + if meta['debug']: + upload_start_time = time.time() + + os.chdir(f"{meta['base_dir']}/tmp/{meta['uuid']}") + initial_img_host = config['DEFAULT'][f'img_host_{img_host_num}'] + img_host = meta['imghost'] + if meta['debug']: + console.print(f"[blue]Using image host: {img_host} (configured: {initial_img_host})[/blue]") + using_custom_img_list = isinstance(custom_img_list, list) and bool(custom_img_list) + + if 'image_sizes' not in meta: + meta['image_sizes'] = {} + + # Handle image selection + if using_custom_img_list: + image_glob = custom_img_list + existing_images = [] + existing_count = 0 + else: + image_patterns = ["*.png", ".[!.]*.png"] + image_glob = [] + for pattern in image_patterns: + glob_results = await asyncio.to_thread(glob.glob, pattern) + image_glob.extend(glob_results) + + unwanted_patterns = ["FILE*", "PLAYLIST*", "POSTER*"] + unwanted_files = set() + for pattern in unwanted_patterns: + glob_results = await asyncio.to_thread(glob.glob, pattern) + unwanted_files.update(glob_results) + if pattern.startswith("FILE") or pattern.startswith("PLAYLIST") or pattern.startswith("POSTER"): + hidden_pattern = "." + pattern + hidden_glob_results = await asyncio.to_thread(glob.glob, hidden_pattern) + unwanted_files.update(hidden_glob_results) + + image_glob = [file for file in image_glob if file not in unwanted_files] + image_glob = list(set(image_glob)) + + # Sort images by numeric suffix + def extract_numeric_suffix(filename): + match = re.search(r"-(\d+)\.png$", filename) + return int(match.group(1)) if match else float('inf') + + image_glob.sort(key=extract_numeric_suffix) + + if meta['debug']: + console.print("image globs (sorted):", image_glob) + + existing_images = [img for img in meta['image_list'] if img.get('img_url') and img.get('web_url')] + existing_count = len(existing_images) + + # Determine images needed + images_needed = total_screens - existing_count if not retry_mode else total_screens + if meta['debug']: + console.print(f"[blue]Existing images: {existing_count}, Images needed: {images_needed}, Total screens: {total_screens}[/blue]") + + if existing_count >= total_screens and not retry_mode and img_host == initial_img_host and not using_custom_img_list: + console.print(f"[yellow]Skipping upload: {existing_count} existing, {total_screens} required.") + return meta['image_list'], total_screens + + upload_tasks = [ + (index, image, img_host, config, meta) + for index, image in enumerate(image_glob[:images_needed]) + ] + + # Concurrency Control + default_pool_size = len(upload_tasks) + host_limits = {"onlyimage": 6, "ptscreens": 6, "lensdump": 1, "passtheimage": 6} + pool_size = host_limits.get(img_host, default_pool_size) + max_workers = min(len(upload_tasks), pool_size) + semaphore = asyncio.Semaphore(max_workers) + + # Track running tasks for cancellation + running_tasks = set() + + async def async_upload(task, max_retries=3): + """Upload image with concurrency control and retry logic.""" + index, *task_args = task + retry_count = 0 + + async with semaphore: + while retry_count <= max_retries: + future = None + try: + future = asyncio.create_task(upload_image_task(task_args)) + running_tasks.add(future) + + try: + result = await asyncio.wait_for(future, timeout=60.0) + running_tasks.discard(future) + + if result.get('status') == 'success': + return (index, result) + else: + reason = result.get('reason', 'Unknown error') + if "duplicate" in reason.lower(): + console.print(f"[yellow]Skipping host because duplicate image {index}: {reason}[/yellow]") + return None + elif "api key" in reason.lower(): + console.print(f"[red]API key error for {img_host}. Aborting further attempts.[/red]") + return None + if retry_count < max_retries: + retry_count += 1 + console.print(f"[yellow]Retry {retry_count}/{max_retries} for image {index}: {reason}[/yellow]") + await asyncio.sleep(1.1 * retry_count) + continue + else: + console.print(f"[red]Failed to upload image {index} after {max_retries} attempts: {reason}[/red]") + return None + + except asyncio.TimeoutError: + console.print(f"[red]Upload task {index} timed out after 60 seconds[/red]") + if future in running_tasks: + future.cancel() + running_tasks.discard(future) + + if retry_count < max_retries: + retry_count += 1 + console.print(f"[yellow]Retry {retry_count}/{max_retries} for image {index} after timeout[/yellow]") + await asyncio.sleep(1.1 * retry_count) + continue + return None + + except asyncio.CancelledError: + console.print(f"[red]Upload task {index} cancelled.[/red]") + if future and future in running_tasks: + future.cancel() + running_tasks.discard(future) + return None + + except Exception as e: + console.print(f"[red]Error during upload for image {index}: {str(e)}[/red]") + if retry_count < max_retries: + retry_count += 1 + console.print(f"[yellow]Retry {retry_count}/{max_retries} for image {index}: {str(e)}[/yellow]") + await asyncio.sleep(1.5 * retry_count) + continue + else: + console.print(f"[red]Error during upload for image {index} after {max_retries} attempts: {str(e)}[/red]") + return None + + try: + max_retries = 3 + try: + upload_results = await asyncio.gather(*[async_upload(task, max_retries) for task in upload_tasks]) + results = [res for res in upload_results if res is not None] + results.sort(key=lambda x: x[0]) + except Exception as e: + console.print(f"[red]Error during uploads: {str(e)}[/red]") + + successfully_uploaded = [(index, result) for index, result in results if result['status'] == 'success'] + if meta['debug']: + console.print(f"[blue]Successfully uploaded {len(successfully_uploaded)} out of {len(upload_tasks)} attempted uploads.[/blue]") + + # Ensure we only switch hosts if necessary + if meta['debug']: + console.print(f"[blue]Double checking current image host: {img_host}, Initial image host: {initial_img_host}[/blue]") + console.print(f"[blue]retry_mode: {retry_mode}, using_custom_img_list: {using_custom_img_list}[/blue]") + console.print(f"[blue]successfully_uploaded={len(successfully_uploaded)}, meta['image_list']={len(meta['image_list'])}, cutoff={meta.get('cutoff', 1)}[/blue]") + if (len(successfully_uploaded) + len(meta['image_list'])) < meta.get('cutoff', 1) and not retry_mode and img_host == initial_img_host and not using_custom_img_list: + img_host_num += 1 + if f'img_host_{img_host_num}' in config['DEFAULT']: + meta['imghost'] = config['DEFAULT'][f'img_host_{img_host_num}'] + console.print(f"[cyan]Switching to the next image host: {meta['imghost']}[/cyan]") + + gc.collect() + return await upload_screens(meta, screens, img_host_num, i, total_screens, custom_img_list, return_dict, retry_mode=True) + else: + console.print("[red]No more image hosts available. Aborting upload process.") + return meta['image_list'], len(meta['image_list']) + + # Process and store successfully uploaded images + new_images = [] + for index, upload in successfully_uploaded: + raw_url = upload['raw_url'] + new_image = { + 'img_url': upload['img_url'], + 'raw_url': raw_url, + 'web_url': upload['web_url'] + } + new_images.append(new_image) + if not using_custom_img_list and raw_url not in {img['raw_url'] for img in meta['image_list']}: + if meta['debug']: + console.print(f"[blue]Adding {raw_url} to image_list") + meta['image_list'].append(new_image) + local_file_path = upload.get('local_file_path') + if local_file_path: + image_size = os.path.getsize(local_file_path) + meta['image_sizes'][raw_url] = image_size + + if len(new_images) and len(new_images) > 0: + if not using_custom_img_list: + console.print(f"[green]Successfully obtained and uploaded {len(new_images)} images.") + else: + raise Exception("No images uploaded. Configure additional image hosts or use a different -ih") + + if not using_custom_img_list: + console.print(f"[green]Successfully obtained and uploaded {len(new_images)} images.") + + if meta['debug']: + console.print(f"Screenshot uploads processed in {time.time() - upload_start_time:.4f} seconds") + + return (new_images, len(new_images)) if using_custom_img_list else (meta['image_list'], len(successfully_uploaded)) + + except asyncio.CancelledError: + console.print("\n[red]Upload process interrupted! Cancelling tasks...[/red]") + + # Cancel running tasks + for task in running_tasks: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + + return meta['image_list'], len(meta['image_list']) + + finally: + # Cleanup + thread_pool.shutdown(wait=True) + gc.collect() + + +async def imgbox_upload(chdir, image_glob, meta, return_dict): + try: + os.chdir(chdir) + image_list = [] + + async with pyimgbox.Gallery(thumb_width=350, square_thumbs=False) as gallery: + for image in image_glob: + try: + async for submission in gallery.add([image]): + if not submission['success']: + console.print(f"[red]Error uploading to imgbox: [yellow]{submission['error']}[/yellow][/red]") + else: + web_url = submission.get('web_url') + img_url = submission.get('thumbnail_url') + raw_url = submission.get('image_url') + if web_url and img_url and raw_url: + image_dict = { + 'web_url': web_url, + 'img_url': img_url, + 'raw_url': raw_url + } + image_list.append(image_dict) + else: + console.print(f"[red]Incomplete URLs received for image: {image}") + except Exception as e: + console.print(f"[red]Error during upload for {image}: {str(e)}") + + return_dict['image_list'] = image_list + return image_list + + except Exception as e: + console.print(f"[red]An error occurred while uploading images to imgbox: {str(e)}") + return [] diff --git a/src/video.py b/src/video.py new file mode 100644 index 000000000..a904b9320 --- /dev/null +++ b/src/video.py @@ -0,0 +1,347 @@ +import cli_ui +import glob +import json +import os +import re +import sys + +from src.cleanup import cleanup, reset_terminal +from src.console import console +from src.exportmi import mi_resolution + + +async def get_uhd(type, guess, resolution, path): + try: + source = guess['Source'] + other = guess['Other'] + except Exception: + source = "" + other = "" + uhd = "" + if source == 'Blu-ray' and other == "Ultra HD" or source == "Ultra HD Blu-ray": + uhd = "UHD" + elif "UHD" in path: + uhd = "UHD" + elif type in ("DISC", "REMUX", "ENCODE", "WEBRIP"): + uhd = "" + + if type in ("DISC", "REMUX", "ENCODE") and resolution == "2160p": + uhd = "UHD" + + return uhd + + +async def get_hdr(mi, bdinfo): + hdr = "" + dv = "" + if bdinfo is not None: # Disks + hdr_mi = bdinfo['video'][0]['hdr_dv'] + if "HDR10+" in hdr_mi: + hdr = "HDR10+" + elif hdr_mi == "HDR10": + hdr = "HDR" + try: + if bdinfo['video'][1]['hdr_dv'] == "Dolby Vision": + dv = "DV" + except Exception: + pass + else: + video_track = mi['media']['track'][1] + try: + hdr_mi = video_track['colour_primaries'] + if hdr_mi in ("BT.2020", "REC.2020"): + hdr = "" + hdr_fields = [ + video_track.get('HDR_Format_Compatibility', ''), + video_track.get('HDR_Format_String', ''), + video_track.get('HDR_Format', '') + ] + hdr_format_string = next((v for v in hdr_fields if isinstance(v, str) and v.strip()), "") + if "HDR10+" in hdr_format_string: + hdr = "HDR10+" + elif "HDR10" in hdr_format_string: + hdr = "HDR" + elif "SMPTE ST 2094 App 4" in hdr_format_string: + hdr = "HDR" + if hdr_format_string and "HLG" in hdr_format_string: + hdr = f"{hdr} HLG" + if hdr_format_string == "" and "PQ" in (video_track.get('transfer_characteristics'), video_track.get('transfer_characteristics_Original', None)): + hdr = "PQ10" + transfer_characteristics = video_track.get('transfer_characteristics_Original', None) + if "HLG" in transfer_characteristics: + hdr = "HLG" + if hdr != "HLG" and "BT.2020 (10-bit)" in transfer_characteristics: + hdr = "WCG" + except Exception: + pass + + try: + if "Dolby Vision" in video_track.get('HDR_Format', '') or "Dolby Vision" in video_track.get('HDR_Format_String', ''): + dv = "DV" + except Exception: + pass + + hdr = f"{dv} {hdr}".strip() + return hdr + + +async def get_video_codec(bdinfo): + codecs = { + "MPEG-2 Video": "MPEG-2", + "MPEG-4 AVC Video": "AVC", + "MPEG-H HEVC Video": "HEVC", + "VC-1 Video": "VC-1" + } + codec = codecs.get(bdinfo['video'][0]['codec'], "") + return codec + + +async def get_video_encode(mi, type, bdinfo): + video_encode = "" + codec = "" + bit_depth = '0' + has_encode_settings = False + try: + format = mi['media']['track'][1]['Format'] + format_profile = mi['media']['track'][1].get('Format_Profile', format) + if mi['media']['track'][1].get('Encoded_Library_Settings', None): + has_encode_settings = True + bit_depth = mi['media']['track'][1].get('BitDepth', '0') + encoded_library_name = mi['media']['track'][1].get('Encoded_Library_Name', None) + except Exception: + format = bdinfo['video'][0]['codec'] + format_profile = bdinfo['video'][0]['profile'] + if type in ("ENCODE", "WEBRIP", "DVDRIP"): # ENCODE or WEBRIP or DVDRIP + if format == 'AVC': + codec = 'x264' + elif format == 'HEVC': + codec = 'x265' + elif format == 'AV1': + codec = 'AV1' + elif format == 'MPEG-4 Visual': + if encoded_library_name: + if 'xvid' in encoded_library_name.lower(): + codec = 'XviD' + elif 'divx' in encoded_library_name.lower(): + codec = 'DivX' + elif type in ('WEBDL', 'HDTV'): # WEB-DL + if format == 'AVC': + codec = 'H.264' + elif format == 'HEVC': + codec = 'H.265' + elif format == 'AV1': + codec = 'AV1' + + if type == 'HDTV' and has_encode_settings is True: + codec = codec.replace('H.', 'x') + elif format == "VP9": + codec = "VP9" + elif format == "VC-1": + codec = "VC-1" + if format_profile == 'High 10': + profile = "Hi10P" + else: + profile = "" + video_encode = f"{profile} {codec}" + video_codec = format + if video_codec == "MPEG Video": + video_codec = f"MPEG-{mi['media']['track'][1].get('Format_Version')}" + return video_encode, video_codec, has_encode_settings, bit_depth + + +async def get_video(videoloc, mode, sorted_filelist=False): + filelist = [] + videoloc = os.path.abspath(videoloc) + if os.path.isdir(videoloc): + globlist = glob.glob1(videoloc, "*.mkv") + glob.glob1(videoloc, "*.mp4") + glob.glob1(videoloc, "*.ts") + for file in globlist: + if not file.lower().endswith('sample.mkv') or "!sample" in file.lower(): + filelist.append(os.path.abspath(f"{videoloc}{os.sep}{file}")) + filelist = sorted(filelist) + if len(filelist) > 1: + for f in filelist: + if "sample" in os.path.basename(f).lower(): + console.print("[green]Filelist:[/green]") + for tf in filelist: + console.print(f"[cyan]{tf}") + console.print(f"[bold red]Possible sample file detected in filelist!: [yellow]{f}") + try: + if cli_ui.ask_yes_no("Do you want to remove it?", default="yes"): + filelist.remove(f) + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + try: + if sorted_filelist: + video = sorted(filelist, key=os.path.getsize, reverse=True)[0] + else: + video = sorted(filelist)[0] + except IndexError: + console.print("[bold red]No Video files found") + if mode == 'cli': + exit() + else: + video = videoloc + filelist.append(videoloc) + if sorted_filelist: + filelist = sorted(filelist, key=os.path.getsize, reverse=True) + else: + filelist = sorted(filelist) + return video, filelist + + +async def get_resolution(guess, folder_id, base_dir): + hfr = False + with open(f'{base_dir}/tmp/{folder_id}/MediaInfo.json', 'r', encoding='utf-8') as f: + mi = json.load(f) + try: + width = mi['media']['track'][1]['Width'] + height = mi['media']['track'][1]['Height'] + except Exception: + width = 0 + height = 0 + + framerate = mi['media']['track'][1].get('FrameRate') + if not framerate or framerate == '0': + framerate = mi['media']['track'][1].get('FrameRate_Original') + if not framerate or framerate == '0': + framerate = mi['media']['track'][1].get('FrameRate_Num') + if framerate: + try: + if int(float(framerate)) > 30: + hfr = True + except Exception: + hfr = False + else: + framerate = "24.000" + + try: + scan = mi['media']['track'][1]['ScanType'] + except Exception: + scan = "Progressive" + if scan == "Progressive": + scan = "p" + elif scan == "Interlaced": + scan = 'i' + elif framerate == "25.000": + scan = "p" + else: + # Fallback using regex on meta['uuid'] - mainly for HUNO fun and games. + match = re.search(r'\b(1080p|720p|2160p|576p|480p)\b', folder_id, re.IGNORECASE) + if match: + scan = "p" # Assume progressive based on common resolution markers + else: + scan = "i" # Default to interlaced if no indicators are found + width_list = [3840, 2560, 1920, 1280, 1024, 854, 720, 15360, 7680, 0] + height_list = [2160, 1440, 1080, 720, 576, 540, 480, 8640, 4320, 0] + width = await closest(width_list, int(width)) + actual_height = int(height) + height = await closest(height_list, int(height)) + res = f"{width}x{height}{scan}" + resolution = await mi_resolution(res, guess, width, scan, height, actual_height) + return resolution, hfr + + +async def closest(lst, K): + # Get closest, but not over + lst = sorted(lst) + mi_input = K + res = 0 + for each in lst: + if mi_input > each: + pass + else: + res = each + break + return res + + +async def get_type(video, scene, is_disc, meta): + if meta.get('manual_type'): + type = meta.get('manual_type') + else: + filename = os.path.basename(video).lower() + if "remux" in filename: + type = "REMUX" + elif any(word in filename for word in [" web ", ".web.", "web-dl", "webdl"]): + type = "WEBDL" + elif "webrip" in filename: + type = "WEBRIP" + # elif scene == True: + # type = "ENCODE" + elif "hdtv" in filename: + type = "HDTV" + elif is_disc is not None: + type = "DISC" + elif "dvdrip" in filename: + type = "DVDRIP" + # exit() + else: + type = "ENCODE" + return type + + +async def is_3d(mi, bdinfo): + if bdinfo is not None: + if bdinfo['video'][0]['3d'] != "": + return "3D" + else: + return "" + else: + return "" + + +async def is_sd(resolution): + if resolution in ("480i", "480p", "576i", "576p", "540p"): + sd = 1 + else: + sd = 0 + return sd + + +async def get_video_duration(meta): + if not meta.get('is_disc') == "BDMV" and meta.get('mediainfo', {}).get('media', {}).get('track'): + general_track = next((track for track in meta['mediainfo']['media']['track'] + if track.get('@type') == 'General'), None) + + if general_track and general_track.get('Duration'): + try: + media_duration_seconds = float(general_track['Duration']) + formatted_duration = int(media_duration_seconds // 60) + return formatted_duration + except ValueError: + if meta['debug']: + console.print(f"[red]Invalid duration value: {general_track['Duration']}[/red]") + return None + else: + if meta['debug']: + console.print("[red]No valid duration found in MediaInfo General track[/red]") + return None + else: + return None + + +async def get_container(meta): + if meta.get('is_disc', '') == 'BDMV': + return 'm2ts' + elif meta.get('is_disc', '') == 'HDDVD': + return 'evo' + elif meta.get('is_disc', '') == 'DVD': + return 'vob' + else: + file_list = meta.get('filelist', []) + + if not file_list: + console.print("[red]No files found to determine container[/red]") + return '' + + try: + largest_file_path = max(file_list, key=os.path.getsize) + except (OSError, ValueError) as e: + console.print(f"[red]Error getting container for file: {e}[/red]") + return '' + + extension = os.path.splitext(largest_file_path)[1] + return extension.lstrip('.').lower() if extension else '' diff --git a/src/vs.py b/src/vs.py index 616b1844e..4209464f6 100644 --- a/src/vs.py +++ b/src/vs.py @@ -1,63 +1,106 @@ import vapoursynth as vs -core = vs.core -from awsmfunc import ScreenGen, DynamicTonemap, FrameInfo, zresize +from awsmfunc import ScreenGen, DynamicTonemap, zresize import random -import argparse -from typing import Union, List -from pathlib import Path -import os, sys +import os from functools import partial -# Modified version of https://git.concertos.live/AHD/ahd_utils/src/branch/master/screengn.py -def vs_screengn(source, encode, filter_b_frames, num, dir): - # prefer ffms2, fallback to lsmash for m2ts +core = vs.core + +# core.std.LoadPlugin(path="/usr/local/lib/vapoursynth/libffms2.so") +# core.std.LoadPlugin(path="/usr/local/lib/vapoursynth/libsub.so") +# core.std.LoadPlugin(path="/usr/local/lib/vapoursynth/libimwri.so") + + +def CustomFrameInfo(clip, text): + def FrameProps(n, f, clip): + # Modify the frame properties extraction here to avoid the decode issue + info = f"Frame {n} of {clip.num_frames}\nPicture type: {f.props['_PictType']}" + # Adding the frame information as text to the clip + return core.text.Text(clip, info) + + # Apply FrameProps to each frame + return core.std.FrameEval(clip, partial(FrameProps, clip=clip), prop_src=clip) + + +def optimize_images(image, config): + import platform # Ensure platform is imported here + if config.get('optimize_images', True): + if os.path.exists(image): + try: + pyver = platform.python_version_tuple() + if int(pyver[0]) == 3 and int(pyver[1]) >= 7: + import oxipng + if os.path.getsize(image) >= 16000000: + oxipng.optimize(image, level=6) + else: + oxipng.optimize(image, level=3) + except Exception as e: + print(f"Image optimization failed: {e}") + return + + +def vs_screengn(source, encode=None, filter_b_frames=False, num=5, dir=".", config=None): + if config is None: + config = {'optimize_images': True} # Default configuration + + screens_file = os.path.join(dir, "screens.txt") + + # Check if screens.txt already exists and use it if valid + if os.path.exists(screens_file): + with open(screens_file, "r") as txt: + frames = [int(line.strip()) for line in txt.readlines()] + if len(frames) == num and all(isinstance(f, int) and 0 <= f for f in frames): + print(f"Using existing frame numbers from {screens_file}") + else: + frames = [] + else: + frames = [] + + # Indexing the source using ffms2 or lsmash for m2ts files if str(source).endswith(".m2ts"): + print(f"Indexing {source} with LSMASHSource... This may take a while.") src = core.lsmas.LWLibavSource(source) else: - src = core.ffms2.Source(source, cachefile=f"{os.path.abspath(dir)}{os.sep}ffms2.ffms2") + cachefile = f"{os.path.abspath(dir)}{os.sep}ffms2.ffms2" + if not os.path.exists(cachefile): + print(f"Indexing {source} with ffms2... This may take a while.") + try: + src = core.ffms2.Source(source, cachefile=cachefile) + except vs.Error as e: + print(f"Error during indexing: {str(e)}") + raise + if os.path.exists(cachefile): + print(f"Indexing completed and cached at: {cachefile}") + else: + print("Indexing did not complete as expected.") - # we don't allow encodes in non-mkv containers anyway + # Check if encode is provided if encode: - enc = core.ffms2.Source(encode) + if not os.path.exists(encode): + print(f"Encode file {encode} not found. Skipping encode processing.") + encode = None + else: + enc = core.ffms2.Source(encode) - # since encodes are optional we use source length + # Use source length if encode is not provided num_frames = len(src) - # these values don't really matter, they're just to cut off intros/credits start, end = 1000, num_frames - 10000 - # filter b frames function for frameeval - def filter_ftype(n, f, clip, frame, frames, ftype="B"): - if f.props["_PictType"].decode() == ftype: - frames.append(frame) - return clip - - # generate random frame numbers, sort, and format for ScreenGen - # if filter option is on filter out non-b frames in encode - frames = [] - if filter_b_frames: - with open(os.devnull, "wb") as f: - i = 0 - while len(frames) < num: - frame = random.randint(start, end) - enc_f = enc[frame] - enc_f = enc_f.std.FrameEval(partial(filter_ftype, clip=enc_f, frame=frame, frames=frames), enc_f) - enc_f.output(f) - i += 1 - if i > num * 10: - raise ValueError("screengn: Encode doesn't seem to contain desired picture type frames.") - else: + # Generate random frame numbers for screenshots if not using existing ones + if not frames: for _ in range(num): frames.append(random.randint(start, end)) - frames = sorted(frames) - frames = [f"{x}\n" for x in frames] + frames = sorted(frames) + frames = [f"{x}\n" for x in frames] - # write to file, we might want to re-use these later - with open("screens.txt", "w") as txt: - txt.writelines(frames) + # Write the frame numbers to a file for reuse + with open(screens_file, "w") as txt: + txt.writelines(frames) + print(f"Generated and saved new frame numbers to {screens_file}") - # if an encode exists we have to crop and resize + # If an encode exists and is provided, crop and resize if encode: - if src.width != enc.width and src.height != enc.height: + if src.width != enc.width or src.height != enc.height: ref = zresize(enc, preset=src.height) crop = [(src.width - ref.width) / 2, (src.height - ref.height) / 2] src = src.std.Crop(left=crop[0], right=crop[0], top=crop[1], bottom=crop[1]) @@ -69,19 +112,25 @@ def filter_ftype(n, f, clip, frame, frames, ftype="B"): height = enc.height src = zresize(src, width=width, height=height) - # tonemap HDR + # Apply tonemapping if the source is HDR tonemapped = False if src.get_frame(0).props["_Primaries"] == 9: tonemapped = True - src = DynamicTonemap(src, src_fmt=False, libplacebo=False, adjust_gamma=True) + src = DynamicTonemap(src, src_fmt=False, libplacebo=True, adjust_gamma=True) if encode: - enc = DynamicTonemap(enc, src_fmt=False, libplacebo=False, adjust_gamma=True) + enc = DynamicTonemap(enc, src_fmt=False, libplacebo=True, adjust_gamma=True) - # add FrameInfo - if tonemapped == True: - src = FrameInfo(src, "Tonemapped") + # Use the custom FrameInfo function + if tonemapped: + src = CustomFrameInfo(src, "Tonemapped") + + # Generate screenshots ScreenGen(src, dir, "a") if encode: - if tonemapped == True: - enc = FrameInfo(enc, "Encode (Tonemapped)") - ScreenGen(enc, dir, "b") \ No newline at end of file + enc = CustomFrameInfo(enc, "Encode (Tonemapped)") + ScreenGen(enc, dir, "b") + + # Optimize images + for i in range(1, num + 1): + image_path = os.path.join(dir, f"{str(i).zfill(2)}a.png") + optimize_images(image_path, config) diff --git a/upload.py b/upload.py index 0c7292865..04b0fcd39 100644 --- a/upload.py +++ b/upload.py @@ -1,570 +1,1004 @@ -import requests -from src.args import Args -from src.clients import Clients -from src.prep import Prep -from src.trackers.COMMON import COMMON -from src.trackers.HUNO import HUNO -from src.trackers.BLU import BLU -from src.trackers.BHD import BHD -from src.trackers.AITHER import AITHER -from src.trackers.STC import STC -from src.trackers.R4E import R4E -from src.trackers.THR import THR -from src.trackers.STT import STT -from src.trackers.HP import HP -from src.trackers.PTP import PTP -from src.trackers.SN import SN -from src.trackers.ACM import ACM -from src.trackers.HDB import HDB -from src.trackers.LCD import LCD -from src.trackers.TTG import TTG -from src.trackers.LST import LST -from src.trackers.FL import FL -from src.trackers.LT import LT -from src.trackers.NBL import NBL -from src.trackers.ANT import ANT -from src.trackers.PTER import PTER -from src.trackers.MTV import MTV -from src.trackers.JPTV import JPTV -from src.trackers.TL import TL -from src.trackers.TDC import TDC -from src.trackers.HDT import HDT -from src.trackers.RF import RF -from src.trackers.OE import OE -from src.trackers.BHDTV import BHDTV -from src.trackers.RTF import RTF -import json -from pathlib import Path +#!/usr/bin/env python3 import asyncio +import cli_ui +import discord +import gc +import json import os -import sys import platform -import multiprocessing -import logging +import re +import requests import shutil -import glob -import cli_ui - -from src.console import console -from rich.markdown import Markdown -from rich.style import Style - +import sys +import time +import traceback +from packaging import version +from pathlib import Path -cli_ui.setup(color='always', title="L4G's Upload Assistant") -import traceback +from bin.get_mkbrr import ensure_mkbrr_binary +from cogs.redaction import clean_meta_for_export, redact_private_info +from discordbot import send_discord_notification, send_upload_status_notification +from src.add_comparison import add_comparison +from src.args import Args +from src.cleanup import cleanup, reset_terminal +from src.clients import Clients +from src.console import console +from src.get_name import get_name +from src.get_desc import gen_desc +from src.get_tracker_data import get_tracker_data +from src.languages import process_desc_language +from src.nfo_link import nfo_link +from src.queuemanage import handle_queue +from src.takescreens import disc_screenshots, dvd_screenshots, screenshots +from src.torrentcreate import create_torrent, create_random_torrents, create_base_from_existing_torrent +from src.trackerhandle import process_trackers +from src.trackerstatus import process_all_trackers +from src.trackersetup import TRACKER_SETUP, tracker_class_map, api_trackers, other_api_trackers, http_trackers +from src.uphelper import UploadHelper +from src.uploadscreens import upload_screens +cli_ui.setup(color='always', title="Upload Assistant") +running_subprocesses = set() base_dir = os.path.abspath(os.path.dirname(__file__)) try: from data.config import config -except: +except Exception: if not os.path.exists(os.path.abspath(f"{base_dir}/data/config.py")): - try: - if os.path.exists(os.path.abspath(f"{base_dir}/data/config.json")): - with open(f"{base_dir}/data/config.json", 'r', encoding='utf-8-sig') as f: - json_config = json.load(f) - f.close() - with open(f"{base_dir}/data/config.py", 'w') as f: - f.write(f"config = {json.dumps(json_config, indent=4)}") - f.close() - cli_ui.info(cli_ui.green, "Successfully updated config from .json to .py") - cli_ui.info(cli_ui.green, "It is now safe for you to delete", cli_ui.yellow, "data/config.json", "if you wish") - from data.config import config - else: - raise NotImplementedError - except: - cli_ui.info(cli_ui.red, "We have switched from .json to .py for config to have a much more lenient experience") - cli_ui.info(cli_ui.red, "Looks like the auto updater didnt work though") - cli_ui.info(cli_ui.red, "Updating is just 2 easy steps:") - cli_ui.info(cli_ui.red, "1: Rename", cli_ui.yellow, os.path.abspath(f"{base_dir}/data/config.json"), cli_ui.red, "to", cli_ui.green, os.path.abspath(f"{base_dir}/data/config.py") ) - cli_ui.info(cli_ui.red, "2: Add", cli_ui.green, "config = ", cli_ui.red, "to the beginning of", cli_ui.green, os.path.abspath(f"{base_dir}/data/config.py")) - exit() + cli_ui.info(cli_ui.red, "Configuration file 'config.py' not found.") + cli_ui.info(cli_ui.red, "Please ensure the file is located at:", cli_ui.yellow, os.path.abspath(f"{base_dir}/data/config.py")) + cli_ui.info(cli_ui.red, "Follow the setup instructions: https://github.com/Audionut/Upload-Assistant") + exit() else: console.print(traceback.print_exc()) + +from src.prep import Prep # noqa E402 client = Clients(config=config) parser = Args(config) +use_discord = False +discord_config = config.get('DISCORD') +if discord_config: + use_discord = discord_config.get('use_discord', False) -async def do_the_thing(base_dir): - meta = dict() - meta['base_dir'] = base_dir - paths = [] - for each in sys.argv[1:]: - if os.path.exists(each): - paths.append(os.path.abspath(each)) - else: - break - meta, help, before_args = parser.parse(tuple(' '.join(sys.argv[1:]).split(' ')), meta) - if meta['cleanup'] and os.path.exists(f"{base_dir}/tmp"): - shutil.rmtree(f"{base_dir}/tmp") - console.print("[bold green]Sucessfully emptied tmp directory") - if not meta['path']: - exit(0) - path = meta['path'] - path = os.path.abspath(path) - if path.endswith('"'): - path = path[:-1] - queue = [] - if os.path.exists(path): - meta, help, before_args = parser.parse(tuple(' '.join(sys.argv[1:]).split(' ')), meta) - queue = [path] - else: - # Search glob if dirname exists - if os.path.exists(os.path.dirname(path)) and len(paths) <= 1: - escaped_path = path.replace('[', '[[]') - globs = glob.glob(escaped_path) - queue = globs - if len(queue) != 0: - md_text = "\n - ".join(queue) - console.print("\n[bold green]Queuing these files:[/bold green]", end='') - console.print(Markdown(f"- {md_text.rstrip()}\n\n", style=Style(color='cyan'))) - console.print("\n\n") + +async def merge_meta(meta, saved_meta, path): + """Merges saved metadata with the current meta, respecting overwrite rules.""" + with open(f"{base_dir}/tmp/{os.path.basename(path)}/meta.json") as f: + saved_meta = json.load(f) + overwrite_list = [ + 'trackers', 'dupe', 'debug', 'anon', 'category', 'type', 'screens', 'nohash', 'manual_edition', 'imdb', 'tmdb_manual', 'mal', 'manual', + 'hdb', 'ptp', 'blu', 'no_season', 'no_aka', 'no_year', 'no_dub', 'no_tag', 'no_seed', 'client', 'desclink', 'descfile', 'desc', 'draft', + 'modq', 'region', 'freeleech', 'personalrelease', 'unattended', 'manual_season', 'manual_episode', 'torrent_creation', 'qbit_tag', 'qbit_cat', + 'skip_imghost_upload', 'imghost', 'manual_source', 'webdv', 'hardcoded-subs', 'dual_audio', 'manual_type', 'tvmaze_manual' + ] + sanitized_saved_meta = {} + for key, value in saved_meta.items(): + clean_key = key.strip().strip("'").strip('"') + if clean_key in overwrite_list: + if clean_key in meta and meta.get(clean_key) is not None: + sanitized_saved_meta[clean_key] = meta[clean_key] + if meta['debug']: + console.print(f"Overriding {clean_key} with meta value:", meta[clean_key]) + else: + sanitized_saved_meta[clean_key] = value else: - console.print(f"[red]Path: [bold red]{path}[/bold red] does not exist") - - elif os.path.exists(os.path.dirname(path)) and len(paths) != 1: - queue = paths - md_text = "\n - ".join(queue) - console.print("\n[bold green]Queuing these files:[/bold green]", end='') - console.print(Markdown(f"- {md_text.rstrip()}\n\n", style=Style(color='cyan'))) - console.print("\n\n") - elif not os.path.exists(os.path.dirname(path)): - split_path = path.split() - p1 = split_path[0] - for i, each in enumerate(split_path): - try: - if os.path.exists(p1) and not os.path.exists(f"{p1} {split_path[i+1]}"): - queue.append(p1) - p1 = split_path[i+1] - else: - p1 += f" {split_path[i+1]}" - except IndexError: - if os.path.exists(p1): - queue.append(p1) - else: - console.print(f"[red]Path: [bold red]{p1}[/bold red] does not exist") - if len(queue) >= 1: - md_text = "\n - ".join(queue) - console.print("\n[bold green]Queuing these files:[/bold green]", end='') - console.print(Markdown(f"- {md_text.rstrip()}\n\n", style=Style(color='cyan'))) - console.print("\n\n") - - else: - # Add Search Here - console.print(f"[red]There was an issue with your input. If you think this was not an issue, please make a report that includes the full command used.") - exit() + sanitized_saved_meta[clean_key] = value + meta.update(sanitized_saved_meta) + f.close() + return sanitized_saved_meta + + +async def print_progress(message, interval=10): + """Prints a progress message every `interval` seconds until cancelled.""" + try: + while True: + await asyncio.sleep(interval) + console.print(message) + except asyncio.CancelledError: + pass - base_meta = {k: v for k, v in meta.items()} - for path in queue: - meta = {k: v for k, v in base_meta.items()} - meta['path'] = path - meta['uuid'] = None +def update_oeimg_to_onlyimage(): + """Update all img_host_* values from 'oeimg' to 'onlyimage' in the config file.""" + config_path = f"{base_dir}/data/config.py" + with open(config_path, "r", encoding="utf-8") as f: + content = f.read() + + new_content = re.sub( + r"(['\"]img_host_\d+['\"]\s*:\s*)['\"]oeimg['\"]", + r"\1'onlyimage'", + content + ) + new_content = re.sub( + r"(['\"])(oeimg_api)(['\"]\s*:)", + r"\1onlyimage_api\3", + new_content + ) + + if new_content != content: + with open(config_path, "w", encoding="utf-8") as f: + f.write(new_content) + console.print("[green]Updated 'oeimg' to 'onlyimage' and 'oeimg_api' to 'onlyimage_api' in config.py[/green]") + else: + console.print("[yellow]No 'oeimg' or 'oeimg_api' found to update in config.py[/yellow]") + + +async def process_meta(meta, base_dir, bot=None): + """Process the metadata for each queued path.""" + if use_discord and bot: + await send_discord_notification(config, bot, f"Starting upload process for: {meta['path']}", debug=meta.get('debug', False), meta=meta) + + if meta['imghost'] is None: + meta['imghost'] = config['DEFAULT']['img_host_1'] try: - with open(f"{base_dir}/tmp/{os.path.basename(path)}/meta.json") as f: - saved_meta = json.load(f) - for key, value in saved_meta.items(): - overwrite_list = [ - 'trackers', 'dupe', 'debug', 'anon', 'category', 'type', 'screens', 'nohash', 'manual_edition', 'imdb', 'tmdb_manual', 'mal', 'manual', - 'hdb', 'ptp', 'blu', 'no_season', 'no_aka', 'no_year', 'no_dub', 'no_tag', 'no_seed', 'client', 'desclink', 'descfile', 'desc', 'draft', 'region', 'freeleech', - 'personalrelease', 'unattended', 'season', 'episode', 'torrent_creation', 'qbit_tag', 'qbit_cat', 'skip_imghost_upload', 'imghost', 'manual_source', 'webdv', 'hardcoded-subs' - ] - if meta.get(key, None) != value and key in overwrite_list: - saved_meta[key] = meta[key] - meta = saved_meta - f.close() - except FileNotFoundError: - pass - console.print(f"[green]Gathering info for {os.path.basename(path)}") - if meta['imghost'] == None: - meta['imghost'] = config['DEFAULT']['img_host_1'] - if not meta['unattended']: - ua = config['DEFAULT'].get('auto_mode', False) - if str(ua).lower() == "true": - meta['unattended'] = True - console.print("[yellow]Running in Auto Mode") - prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=config) - meta = await prep.gather_prep(meta=meta, mode='cli') - meta['name_notag'], meta['name'], meta['clean_name'], meta['potential_missing'] = await prep.get_name(meta) - - if meta.get('image_list', False) in (False, []) and meta.get('skip_imghost_upload', False) == False: - return_dict = {} - meta['image_list'], dummy_var = prep.upload_screens(meta, meta['screens'], 1, 0, meta['screens'],[], return_dict) - if meta['debug']: - console.print(meta['image_list']) - # meta['uploaded_screens'] = True - elif meta.get('skip_imghost_upload', False) == True and meta.get('image_list', False) == False: - meta['image_list'] = [] + result = any( + config['DEFAULT'].get(key) == "oeimg" + for key in config['DEFAULT'] + if key.startswith("img_host_") + ) + if result: + console.print("[red]oeimg is now onlyimage, your config is being updated[/red]") + update_oeimg_to_onlyimage() + except Exception as e: + console.print(f"[red]Error checking image hosts: {e}[/red]") + return - if not os.path.exists(os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent")): - reuse_torrent = None - if meta.get('rehash', False) == False: - reuse_torrent = await client.find_existing_torrent(meta) - if reuse_torrent != None: - prep.create_base_from_existing_torrent(reuse_torrent, meta['base_dir'], meta['uuid']) - if meta['nohash'] == False and reuse_torrent == None: - prep.create_torrent(meta, Path(meta['path']), "BASE", meta.get('piece_size_max', 0)) - if meta['nohash']: - meta['client'] = "none" - elif os.path.exists(os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent")) and meta.get('rehash', False) == True and meta['nohash'] == False: - prep.create_torrent(meta, Path(meta['path']), "BASE", meta.get('piece_size_max', 0)) - if int(meta.get('randomized', 0)) >= 1: - prep.create_random_torrents(meta['base_dir'], meta['uuid'], meta['randomized'], meta['path']) - - if meta.get('trackers', None) != None: + if not meta['unattended']: + ua = config['DEFAULT'].get('auto_mode', False) + if str(ua).lower() == "true": + meta['unattended'] = True + console.print("[yellow]Running in Auto Mode") + meta['base_dir'] = base_dir + prep = Prep(screens=meta['screens'], img_host=meta['imghost'], config=config) + try: + meta = await prep.gather_prep(meta=meta, mode='cli') + except Exception as e: + console.print(f"Error in gather_prep: {e}") + console.print(traceback.format_exc()) + return + + meta['emby_debug'] = meta.get('emby_debug') if meta.get('emby_debug', False) else config['DEFAULT'].get('emby_debug', False) + if meta.get('emby_cat', None) == "movie" and meta.get('category', None) != "MOVIE": + console.print(f"[red]Wrong category detected! Expected 'MOVIE', but found: {meta.get('category', None)}[/red]") + meta['we_are_uploading'] = False + return + elif meta.get('emby_cat', None) == "tv" and meta.get('category', None) != "TV": + console.print("[red]TV content is not supported at this time[/red]") + meta['we_are_uploading'] = False + return + + # If unattended confirm and we had to get metadata ids from filename searching, skip the quick return so we can prompt about database information + if meta.get('emby', False) and not meta.get('no_ids', False) and not meta.get('unattended_confirm', False) and meta.get('unattended', False): + await nfo_link(meta) + meta['we_are_uploading'] = False + return + + parser = Args(config) + helper = UploadHelper() + + if not meta.get('emby', False): + if meta.get('trackers'): trackers = meta['trackers'] else: - trackers = config['TRACKERS']['default_trackers'] - if "," in trackers: - trackers = trackers.split(',') - with open (f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: + default_trackers = config['TRACKERS'].get('default_trackers', '') + trackers = [tracker.strip() for tracker in default_trackers.split(',')] + + if isinstance(trackers, str): + if "," in trackers: + trackers = [t.strip().upper() for t in trackers.split(',')] + else: + trackers = [trackers.strip().upper()] # Make it a list with one element + else: + trackers = [t.strip().upper() for t in trackers] + meta['trackers'] = trackers + + if meta.get('trackers_remove', False): + remove_list = [t.strip().upper() for t in meta['trackers_remove'].split(',')] + for tracker in remove_list: + if tracker in meta['trackers']: + meta['trackers'].remove(tracker) + + meta['name_notag'], meta['name'], meta['clean_name'], meta['potential_missing'] = await get_name(meta) + + if meta['debug']: + console.print(f"Trackers list before editing: {meta['trackers']}") + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: json.dump(meta, f, indent=4) f.close() - confirm = get_confirmation(meta) - while confirm == False: - # help.print_help() - editargs = cli_ui.ask_string("Input args that need correction e.g.(--tag NTb --category tv --tmdb 12345)") - editargs = (meta['path'],) + tuple(editargs.split()) - if meta['debug']: - editargs = editargs + ("--debug",) - meta, help, before_args = parser.parse(editargs, meta) - # meta = await prep.tmdb_other_meta(meta) - meta['edit'] = True - meta = await prep.gather_prep(meta=meta, mode='cli') - meta['name_notag'], meta['name'], meta['clean_name'], meta['potential_missing'] = await prep.get_name(meta) - confirm = get_confirmation(meta) - - if isinstance(trackers, list) == False: - trackers = [trackers] - trackers = [s.strip().upper() for s in trackers] - if meta.get('manual', False): - trackers.insert(0, "MANUAL") - - - - #################################### - ####### Upload to Trackers ####### - #################################### - common = COMMON(config=config) - api_trackers = ['BLU', 'AITHER', 'STC', 'R4E', 'STT', 'RF', 'ACM','LCD','LST','HUNO', 'SN', 'LT', 'NBL', 'ANT', 'JPTV', 'TDC', 'OE', 'BHDTV', 'RTF'] - http_trackers = ['HDB', 'TTG', 'FL', 'PTER', 'HDT', 'MTV'] - tracker_class_map = { - 'BLU' : BLU, 'BHD': BHD, 'AITHER' : AITHER, 'STC' : STC, 'R4E' : R4E, 'THR' : THR, 'STT' : STT, 'HP' : HP, 'PTP' : PTP, 'RF' : RF, 'SN' : SN, - 'ACM' : ACM, 'HDB' : HDB, 'LCD': LCD, 'TTG' : TTG, 'LST' : LST, 'HUNO': HUNO, 'FL' : FL, 'LT' : LT, 'NBL' : NBL, 'ANT' : ANT, 'PTER': PTER, 'JPTV' : JPTV, - 'TL' : TL, 'TDC' : TDC, 'HDT' : HDT, 'MTV': MTV, 'OE': OE, 'BHDTV': BHDTV, 'RTF':RTF} - - for tracker in trackers: - if meta['name'].endswith('DUPE?'): - meta['name'] = meta['name'].replace(' DUPE?', '') - tracker = tracker.replace(" ", "").upper().strip() - if meta['debug']: - debug = "(DEBUG)" + + if meta.get('emby_debug', False): + meta['original_imdb'] = meta.get('imdb_id', None) + meta['original_tmdb'] = meta.get('tmdb_id', None) + meta['original_mal'] = meta.get('mal_id', None) + meta['original_tvmaze'] = meta.get('tvmaze_id', None) + meta['original_tvdb'] = meta.get('tvdb_id', None) + meta['original_category'] = meta.get('category', None) + if 'matched_tracker' not in meta: + await client.get_pathed_torrents(meta['path'], meta) + if meta['is_disc']: + search_term = os.path.basename(meta['path']) + search_file_folder = 'folder' else: - debug = "" - - if tracker in api_trackers: - tracker_class = tracker_class_map[tracker](config=config) - if meta['unattended']: - upload_to_tracker = True - else: - upload_to_tracker = cli_ui.ask_yes_no(f"Upload to {tracker_class.tracker}? {debug}", default=meta['unattended']) - if upload_to_tracker: - console.print(f"Uploading to {tracker_class.tracker}") - if check_banned_group(tracker_class.tracker, tracker_class.banned_groups, meta): - continue - dupes = await tracker_class.search_existing(meta) - dupes = await common.filter_dupes(dupes, meta) - # note BHDTV does not have search implemented. - meta = dupe_check(dupes, meta) - if meta['upload'] == True: - await tracker_class.upload(meta) - if tracker == 'SN': - await asyncio.sleep(16) - await client.add_to_client(meta, tracker_class.tracker) - - if tracker in http_trackers: - tracker_class = tracker_class_map[tracker](config=config) - if meta['unattended']: - upload_to_tracker = True + search_term = os.path.basename(meta['filelist'][0]) if meta['filelist'] else None + search_file_folder = 'file' + await get_tracker_data(meta['video'], meta, search_term, search_file_folder, meta['category'], only_id=meta['only_id']) + + editargs_tracking = () + previous_trackers = meta.get('trackers', []) + try: + confirm = await helper.get_confirmation(meta) + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + while confirm is False: + try: + editargs = cli_ui.ask_string("Input args that need correction e.g. (--tag NTb --category tv --tmdb 12345)") + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + + if editargs == "continue": + break + + if not editargs or not editargs.strip(): + console.print("[yellow]No input provided. Please enter arguments, type `continue` to continue or press Ctrl+C to exit.[/yellow]") + continue + + try: + editargs = tuple(editargs.split()) + except AttributeError: + console.print("[red]Bad input detected[/red]") + confirm = False + continue + # Tracks multiple edits + editargs_tracking = editargs_tracking + editargs + # Carry original args over, let parse handle duplicates + meta, help, before_args = parser.parse(tuple(' '.join(sys.argv[1:]).split(' ')) + editargs_tracking, meta) + if not meta.get('trackers'): + meta['trackers'] = previous_trackers + if isinstance(meta.get('trackers'), str): + if "," in meta['trackers']: + meta['trackers'] = [t.strip().upper() for t in meta['trackers'].split(',')] + else: + meta['trackers'] = [meta['trackers'].strip().upper()] + elif isinstance(meta.get('trackers'), list): + meta['trackers'] = [t.strip().upper() for t in meta['trackers'] if isinstance(t, str)] + if meta['debug']: + console.print(f"Trackers list during edit process: {meta['trackers']}") + meta['edit'] = True + meta = await prep.gather_prep(meta=meta, mode='cli') + meta['name_notag'], meta['name'], meta['clean_name'], meta['potential_missing'] = await get_name(meta) + try: + confirm = await helper.get_confirmation(meta) + except EOFError: + console.print("\n[red]Exiting on user request (Ctrl+C)[/red]") + await cleanup() + reset_terminal() + sys.exit(1) + + if meta.get('emby', False): + if not meta['debug']: + await nfo_link(meta) + meta['we_are_uploading'] = False + return + + if 'remove_trackers' in meta and meta['remove_trackers']: + removed = [] + for tracker in meta['remove_trackers']: + if tracker in meta['trackers']: + if meta['debug']: + console.print(f"[DEBUG] Would have removed {tracker} found in client") else: - upload_to_tracker = cli_ui.ask_yes_no(f"Upload to {tracker_class.tracker}? {debug}", default=meta['unattended']) - if upload_to_tracker: - console.print(f"Uploading to {tracker}") - if check_banned_group(tracker_class.tracker, tracker_class.banned_groups, meta): - continue - if await tracker_class.validate_credentials(meta) == True: - dupes = await tracker_class.search_existing(meta) - dupes = await common.filter_dupes(dupes, meta) - meta = dupe_check(dupes, meta) - if meta['upload'] == True: - await tracker_class.upload(meta) - await client.add_to_client(meta, tracker_class.tracker) - - if tracker == "MANUAL": - if meta['unattended']: - do_manual = True + meta['trackers'].remove(tracker) + removed.append(tracker) + if removed: + console.print(f"[yellow]Removing trackers already in your client: {', '.join(removed)}[/yellow]") + if not meta['trackers']: + console.print("[red]No trackers remain after removal.[/red]") + successful_trackers = 0 + meta['skip_uploading'] = 10 + + else: + console.print(f"[green]Processing {meta['name']} for upload...[/green]") + + audio_prompted = False + for tracker in ["AITHER", "ASC", "BJS", "BT", "CBR", "DP", "FF", "GPW", "HUNO", "LDU", "OE", "PTS", "SHRI", "SPD", "ULCX"]: + if tracker in trackers: + if not audio_prompted: + await process_desc_language(meta, desc=None, tracker=tracker) + audio_prompted = True else: - do_manual = cli_ui.ask_yes_no(f"Get files for manual upload?", default=True) - if do_manual: - for manual_tracker in trackers: - if manual_tracker != 'MANUAL': - manual_tracker = manual_tracker.replace(" ", "").upper().strip() - tracker_class = tracker_class_map[manual_tracker](config=config) - if manual_tracker in api_trackers: - await common.unit3d_edit_desc(meta, tracker_class.tracker, tracker_class.signature) - else: - await tracker_class.edit_desc(meta) - url = await prep.package(meta) - if url == False: - console.print(f"[yellow]Unable to upload prep files, they can be found at `tmp/{meta['uuid']}") + if 'tracker_status' not in meta: + meta['tracker_status'] = {} + if tracker not in meta['tracker_status']: + meta['tracker_status'][tracker] = {} + if meta.get('unattended_audio_skip', False) or meta.get('unattended_subtitle_skip', False): + meta['tracker_status'][tracker]['skip_upload'] = True else: - console.print(f"[green]{meta['name']}") - console.print(f"[green]Files can be found at: [yellow]{url}[/yellow]") - - if tracker == "BHD": - bhd = BHD(config=config) - draft_int = await bhd.get_live(meta) - if draft_int == 0: - draft = "Draft" - else: - draft = "Live" - if meta['unattended']: - upload_to_bhd = True - else: - upload_to_bhd = cli_ui.ask_yes_no(f"Upload to BHD? ({draft}) {debug}", default=meta['unattended']) - if upload_to_bhd: - console.print("Uploading to BHD") - if check_banned_group("BHD", bhd.banned_groups, meta): - continue - dupes = await bhd.search_existing(meta) - dupes = await common.filter_dupes(dupes, meta) - meta = dupe_check(dupes, meta) - if meta['upload'] == True: - await bhd.upload(meta) - await client.add_to_client(meta, "BHD") - - if tracker == "THR": - if meta['unattended']: - upload_to_thr = True - else: - upload_to_thr = cli_ui.ask_yes_no(f"Upload to THR? {debug}", default=meta['unattended']) - if upload_to_thr: - console.print("Uploading to THR") - #Unable to get IMDB id/Youtube Link - if meta.get('imdb_id', '0') == '0': - imdb_id = cli_ui.ask_string("Unable to find IMDB id, please enter e.g.(tt1234567)") - meta['imdb_id'] = imdb_id.replace('tt', '').zfill(7) - if meta.get('youtube', None) == None: - youtube = cli_ui.ask_string("Unable to find youtube trailer, please link one e.g.(https://www.youtube.com/watch?v=dQw4w9WgXcQ)") - meta['youtube'] = youtube - thr = THR(config=config) + meta['tracker_status'][tracker]['skip_upload'] = False + + await asyncio.sleep(0.2) + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: + json.dump(meta, f, indent=4) + await asyncio.sleep(0.2) + + successful_trackers = await process_all_trackers(meta) + + if meta.get('trackers_pass') is not None: + meta['skip_uploading'] = meta.get('trackers_pass') + else: + meta['skip_uploading'] = int(config['DEFAULT'].get('tracker_pass_checks', 1)) + + meta['frame_overlay'] = config['DEFAULT'].get('frame_overlay', False) + if any(tracker in meta['trackers'] for tracker in ['AZ', 'CZ', 'PHD']) and meta['frame_overlay']: + meta['frame_overlay'] = False + console.print("[yellow]AZ, CZ, and PHD do not allow frame overlays. Frame overlay will be disabled for this upload.[/yellow]") + + if successful_trackers < int(meta['skip_uploading']) and not meta['debug']: + console.print(f"[red]Not enough successful trackers ({successful_trackers}/{meta['skip_uploading']}). EXITING........[/red]") + + else: + meta['we_are_uploading'] = True + filename = meta.get('title', None) + bdmv_filename = meta.get('filename', None) + bdinfo = meta.get('bdinfo', None) + videopath = meta.get('filelist', [None]) + videopath = videopath[0] if videopath else None + console.print(f"Processing {filename} for upload.....") + progress_task = asyncio.create_task(print_progress("[yellow]Still processing, please wait...", interval=10)) + try: + if 'manual_frames' not in meta: + meta['manual_frames'] = {} + manual_frames = meta['manual_frames'] + + if meta.get('comparison', False): + await add_comparison(meta) + + else: + image_data_file = f"{meta['base_dir']}/tmp/{meta['uuid']}/image_data.json" + if os.path.exists(image_data_file) and not meta.get('image_list'): try: - with requests.Session() as session: - console.print("[yellow]Logging in to THR") - session = thr.login(session) - console.print("[yellow]Searching for Dupes") - dupes = thr.search_existing(session, meta.get('imdb_id')) - dupes = await common.filter_dupes(dupes, meta) - meta = dupe_check(dupes, meta) - if meta['upload'] == True: - await thr.upload(session, meta) - await client.add_to_client(meta, "THR") - except: - console.print(traceback.print_exc()) - - if tracker == "PTP": - if meta['unattended']: - upload_to_ptp = True + with open(image_data_file, 'r') as img_file: + image_data = json.load(img_file) + + if 'image_list' in image_data and not meta.get('image_list'): + meta['image_list'] = image_data['image_list'] + if meta.get('debug'): + console.print(f"[cyan]Loaded {len(image_data['image_list'])} previously saved image links") + + if 'image_sizes' in image_data and not meta.get('image_sizes'): + meta['image_sizes'] = image_data['image_sizes'] + if meta.get('debug'): + console.print("[cyan]Loaded previously saved image sizes") + + if 'tonemapped' in image_data and not meta.get('tonemapped'): + meta['tonemapped'] = image_data['tonemapped'] + if meta.get('debug'): + console.print("[cyan]Loaded previously saved tonemapped status[/cyan]") + except Exception as e: + console.print(f"[yellow]Could not load saved image data: {str(e)}") + + # Take Screenshots + try: + if meta['is_disc'] == "BDMV": + use_vs = meta.get('vapoursynth', False) + try: + await disc_screenshots( + meta, bdmv_filename, bdinfo, meta['uuid'], base_dir, use_vs, + meta.get('image_list', []), meta.get('ffdebug', False), None + ) + except asyncio.CancelledError: + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + raise Exception("Error during screenshot capture") + except Exception as e: + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + raise Exception(f"Error during screenshot capture: {e}") + + elif meta['is_disc'] == "DVD": + try: + await dvd_screenshots( + meta, 0, None, None + ) + except asyncio.CancelledError: + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + raise Exception("Error during screenshot capture") + except Exception as e: + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + raise Exception(f"Error during screenshot capture: {e}") + + else: + try: + if meta['debug']: + console.print(f"videopath: {videopath}, filename: {filename}, meta: {meta['uuid']}, base_dir: {base_dir}, manual_frames: {manual_frames}") + + await screenshots( + videopath, filename, meta['uuid'], base_dir, meta, + manual_frames=manual_frames # Pass additional kwargs directly + ) + except asyncio.CancelledError: + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + raise Exception("Error during screenshot capture") + except Exception as e: + console.print(traceback.format_exc()) + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + try: + raise Exception(f"Error during screenshot capture: {e}") + except Exception as e2: + if "workers" in str(e2): + console.print("[red]max workers issue, see https://github.com/Audionut/Upload-Assistant/wiki/ffmpeg---max-workers-issues[/red]") + raise e2 + + except asyncio.CancelledError: + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + raise Exception("Error during screenshot capture") + except Exception: + await cleanup_screenshot_temp_files(meta) + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + raise Exception + finally: + await asyncio.sleep(0.1) + await cleanup() + gc.collect() + reset_terminal() + + if 'image_list' not in meta: + meta['image_list'] = [] + manual_frames_str = meta.get('manual_frames', '') + if isinstance(manual_frames_str, str): + manual_frames_list = [f.strip() for f in manual_frames_str.split(',') if f.strip()] + manual_frames_count = len(manual_frames_list) + if meta['debug']: + console.print(f"Manual frames entered: {manual_frames_count}") else: - upload_to_ptp = cli_ui.ask_yes_no(f"Upload to {tracker}? {debug}", default=meta['unattended']) - if upload_to_ptp: - console.print(f"Uploading to {tracker}") - if meta.get('imdb_id', '0') == '0': - imdb_id = cli_ui.ask_string("Unable to find IMDB id, please enter e.g.(tt1234567)") - meta['imdb_id'] = imdb_id.replace('tt', '').zfill(7) - ptp = PTP(config=config) - if check_banned_group("PTP", ptp.banned_groups, meta): - continue + manual_frames_count = 0 + if manual_frames_count > 0: + meta['screens'] = manual_frames_count + if len(meta.get('image_list', [])) < meta.get('cutoff') and meta.get('skip_imghost_upload', False) is False: + return_dict = {} try: - console.print("[yellow]Searching for Group ID") - groupID = await ptp.get_group_by_imdb(meta['imdb_id']) - if groupID == None: - console.print("[yellow]No Existing Group found") - if meta.get('youtube', None) == None or "youtube" not in str(meta.get('youtube', '')): - youtube = cli_ui.ask_string("Unable to find youtube trailer, please link one e.g.(https://www.youtube.com/watch?v=dQw4w9WgXcQ)", default="") - meta['youtube'] = youtube - meta['upload'] = True - else: - console.print("[yellow]Searching for Existing Releases") - dupes = await ptp.search_existing(groupID, meta) - dupes = await common.filter_dupes(dupes, meta) - meta = dupe_check(dupes, meta) - if meta.get('imdb_info', {}) == {}: - meta['imdb_info'] = await prep.get_imdb_info(meta['imdb_id'], meta) - if meta['upload'] == True: - ptpUrl, ptpData = await ptp.fill_upload_form(groupID, meta) - await ptp.upload(meta, ptpUrl, ptpData) - await asyncio.sleep(5) - await client.add_to_client(meta, "PTP") - except: - console.print(traceback.print_exc()) - - if tracker == "TL": - tracker_class = tracker_class_map[tracker](config=config) - if meta['unattended']: - upload_to_tracker = True - else: - upload_to_tracker = cli_ui.ask_yes_no(f"Upload to {tracker_class.tracker}? {debug}", default=meta['unattended']) - if upload_to_tracker: - console.print(f"Uploading to {tracker_class.tracker}") - if check_banned_group(tracker_class.tracker, tracker_class.banned_groups, meta): - continue - await tracker_class.upload(meta) - await client.add_to_client(meta, tracker_class.tracker) - - -def get_confirmation(meta): - if meta['debug'] == True: - console.print("[bold red]DEBUG: True") - console.print(f"Prep material saved to {meta['base_dir']}/tmp/{meta['uuid']}") - console.print() - cli_ui.info_section(cli_ui.yellow, "Database Info") - cli_ui.info(f"Title: {meta['title']} ({meta['year']})") - console.print() - cli_ui.info(f"Overview: {meta['overview']}") - console.print() - cli_ui.info(f"Category: {meta['category']}") - if int(meta.get('tmdb', 0)) != 0: - cli_ui.info(f"TMDB: https://www.themoviedb.org/{meta['category'].lower()}/{meta['tmdb']}") - if int(meta.get('imdb_id', '0')) != 0: - cli_ui.info(f"IMDB: https://www.imdb.com/title/tt{meta['imdb_id']}") - if int(meta.get('tvdb_id', '0')) != 0: - cli_ui.info(f"TVDB: https://www.thetvdb.com/?id={meta['tvdb_id']}&tab=series") - if int(meta.get('mal_id', 0)) != 0: - cli_ui.info(f"MAL : https://myanimelist.net/anime/{meta['mal_id']}") - console.print() - if int(meta.get('freeleech', '0')) != 0: - cli_ui.info(f"Freeleech: {meta['freeleech']}") - if meta['tag'] == "": - tag = "" - else: - tag = f" / {meta['tag'][1:]}" - if meta['is_disc'] == "DVD": - res = meta['source'] - else: - res = meta['resolution'] - - cli_ui.info(f"{res} / {meta['type']}{tag}") - if meta.get('personalrelease', False) == True: - cli_ui.info("Personal Release!") - console.print() - if meta.get('unattended', False) == False: - get_missing(meta) - ring_the_bell = "\a" if config['DEFAULT'].get("sfx_on_prompt", True) == True else "" # \a rings the bell - cli_ui.info_section(cli_ui.yellow, f"Is this correct?{ring_the_bell}") - cli_ui.info(f"Name: {meta['name']}") - confirm = cli_ui.ask_yes_no("Correct?", default=False) - else: - cli_ui.info(f"Name: {meta['name']}") - confirm = True - return confirm - -def dupe_check(dupes, meta): - if not dupes: - console.print("[green]No dupes found") - meta['upload'] = True - return meta + new_images, dummy_var = await upload_screens( + meta, meta['screens'], 1, 0, meta['screens'], [], return_dict=return_dict + ) + except asyncio.CancelledError: + console.print("\n[red]Upload process interrupted! Cancelling tasks...[/red]") + return + except Exception as e: + raise e + finally: + reset_terminal() + if meta['debug']: + console.print("[yellow]Cleaning up resources...[/yellow]") + gc.collect() + + elif meta.get('skip_imghost_upload', False) is True and meta.get('image_list', False) is False: + meta['image_list'] = [] + + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: + json.dump(meta, f, indent=4) + + if 'image_list' in meta and meta['image_list']: + try: + image_data = { + "image_list": meta.get('image_list', []), + "image_sizes": meta.get('image_sizes', {}), + "tonemapped": meta.get('tonemapped', False) + } + + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/image_data.json", 'w') as img_file: + json.dump(image_data, img_file, indent=4) + + if meta.get('debug'): + console.print(f"[cyan]Saved {len(meta['image_list'])} images to image_data.json") + except Exception as e: + console.print(f"[yellow]Failed to save image data: {str(e)}") + finally: + progress_task.cancel() + try: + await progress_task + except asyncio.CancelledError: + pass + + torrent_path = os.path.abspath(f"{meta['base_dir']}/tmp/{meta['uuid']}/BASE.torrent") + if not os.path.exists(torrent_path): + reuse_torrent = None + if meta.get('rehash', False) is False and not meta['base_torrent_created'] and not meta['we_checked_them_all']: + reuse_torrent = await client.find_existing_torrent(meta) + if reuse_torrent is not None: + await create_base_from_existing_torrent(reuse_torrent, meta['base_dir'], meta['uuid']) + + if meta['nohash'] is False and reuse_torrent is None: + create_torrent(meta, Path(meta['path']), "BASE") + if meta['nohash']: + meta['client'] = "none" + + elif os.path.exists(torrent_path) and meta.get('rehash', False) is True and meta['nohash'] is False: + create_torrent(meta, Path(meta['path']), "BASE") + + if int(meta.get('randomized', 0)) >= 1: + if not meta['mkbrr']: + create_random_torrents(meta['base_dir'], meta['uuid'], meta['randomized'], meta['path']) + + meta = await gen_desc(meta) + + with open(f"{meta['base_dir']}/tmp/{meta['uuid']}/meta.json", 'w') as f: + json.dump(meta, f, indent=4) + + +async def cleanup_screenshot_temp_files(meta): + """Cleanup temporary screenshot files to prevent orphaned files in case of failures.""" + tmp_dir = f"{meta['base_dir']}/tmp/{meta['uuid']}" + if os.path.exists(tmp_dir): + try: + for file in os.listdir(tmp_dir): + file_path = os.path.join(tmp_dir, file) + if os.path.isfile(file_path) and file.endswith((".png", ".jpg")): + os.remove(file_path) + if meta['debug']: + console.print(f"[yellow]Removed temporary screenshot file: {file_path}[/yellow]") + except Exception as e: + console.print(f"[red]Error cleaning up temporary screenshot files: {e}[/red]", highlight=False) + + +async def get_log_file(base_dir, queue_name): + """ + Returns the path to the log file for the given base directory and queue name. + """ + safe_queue_name = queue_name.replace(" ", "_") + return os.path.join(base_dir, "tmp", f"{safe_queue_name}_processed_files.log") + + +async def load_processed_files(log_file): + """ + Loads the list of processed files from the log file. + """ + if os.path.exists(log_file): + with open(log_file, "r") as f: + return set(json.load(f)) + return set() + + +async def save_processed_file(log_file, file_path): + """ + Adds a processed file to the log, deduplicating and always appending to the end. + """ + if os.path.exists(log_file): + with open(log_file, "r") as f: + try: + processed_files = json.load(f) + except Exception: + processed_files = [] else: - console.print() - dupe_text = "\n".join(dupes) - console.print() - cli_ui.info_section(cli_ui.bold, "Are these dupes?") - cli_ui.info(dupe_text) - if meta['unattended']: - if meta.get('dupe', False) == False: - console.print("[red]Found potential dupes. Aborting. If this is not a dupe, or you would like to upload anyways, pass --skip-dupe-check") - upload = False - else: - console.print("[yellow]Found potential dupes. --skip-dupe-check was passed. Uploading anyways") - upload = True - console.print() - if not meta['unattended']: - if meta.get('dupe', False) == False: - upload = cli_ui.ask_yes_no("Upload Anyways?", default=False) - else: - upload = True - if upload == False: - meta['upload'] = False + processed_files = [] + + processed_files = [entry for entry in processed_files if entry != file_path] + processed_files.append(file_path) + + with open(log_file, "w") as f: + json.dump(processed_files, f, indent=4) + + +def get_local_version(version_file): + """Extracts the local version from the version.py file.""" + try: + with open(version_file, "r", encoding="utf-8") as f: + content = f.read() + match = re.search(r'__version__\s*=\s*"([^"]+)"', content) + if match: + return match.group(1) else: - meta['upload'] = True - for each in dupes: - if each == meta['name']: - meta['name'] = f"{meta['name']} DUPE?" + console.print("[red]Version not found in local file.") + return None + except FileNotFoundError: + console.print("[red]Version file not found.") + return None - return meta +def get_remote_version(url): + """Fetches the latest version information from the remote repository.""" + try: + response = requests.get(url) + if response.status_code == 200: + content = response.text + match = re.search(r'__version__\s*=\s*"([^"]+)"', content) + if match: + return match.group(1), content + else: + console.print("[red]Version not found in remote file.") + return None, None + else: + console.print(f"[red]Failed to fetch remote version file. Status code: {response.status_code}") + return None, None + except requests.RequestException as e: + console.print(f"[red]An error occurred while fetching the remote version file: {e}") + return None, None -# Return True if banned group -def check_banned_group(tracker, banned_group_list, meta): - if meta['tag'] == "": - return False + +def extract_changelog(content, from_version, to_version): + """Extracts the changelog entries between the specified versions.""" + pattern = rf'__version__\s*=\s*"{re.escape(to_version)}"\s*(.*?)__version__\s*=\s*"{re.escape(from_version)}"' + match = re.search(pattern, content, re.DOTALL) + if match: + return match.group(1).strip() else: - q = False - for tag in banned_group_list: - if isinstance(tag, list): - if meta['tag'][1:].lower() == tag[0].lower(): - console.print(f"[bold yellow]{meta['tag'][1:]}[/bold yellow][bold red] was found on [bold yellow]{tracker}'s[/bold yellow] list of banned groups.") - console.print(f"[bold red]NOTE: [bold yellow]{tag[1]}") - q = True + return None + + +async def update_notification(base_dir): + version_file = os.path.join(base_dir, 'data', 'version.py') + remote_version_url = 'https://raw.githubusercontent.com/Audionut/Upload-Assistant/master/data/version.py' + + notice = config['DEFAULT'].get('update_notification', True) + verbose = config['DEFAULT'].get('verbose_notification', False) + + local_version = get_local_version(version_file) + if not local_version: + return + + if not notice: + return local_version + + remote_version, remote_content = get_remote_version(remote_version_url) + if not remote_version: + return local_version + + if version.parse(remote_version) > version.parse(local_version): + console.print(f"[red][NOTICE] [green]Update available: v[/green][yellow]{remote_version}") + console.print(f"[red][NOTICE] [green]Current version: v[/green][yellow]{local_version}") + asyncio.create_task(asyncio.sleep(1)) + if verbose and remote_content: + changelog = extract_changelog(remote_content, local_version, remote_version) + if changelog: + asyncio.create_task(asyncio.sleep(1)) + console.print(f"{changelog}") else: - if meta['tag'][1:].lower() == tag.lower(): - console.print(f"[bold yellow]{meta['tag'][1:]}[/bold yellow][bold red] was found on [bold yellow]{tracker}'s[/bold yellow] list of banned groups.") - q = True - if q: - if not cli_ui.ask_yes_no(cli_ui.red, "Upload Anyways?", default=False): - return True - return False - -def get_missing(meta): - info_notes = { - 'edition' : 'Special Edition/Release', - 'description' : "Please include Remux/Encode Notes if possible (either here or edit your upload)", - 'service' : "WEB Service e.g.(AMZN, NF)", - 'region' : "Disc Region", - 'imdb' : 'IMDb ID (tt1234567)', - 'distributor' : "Disc Distributor e.g.(BFI, Criterion, etc)" - } - missing = [] - if meta.get('imdb_id', '0') == '0': - meta['imdb_id'] = '0' - meta['potential_missing'].append('imdb_id') - if len(meta['potential_missing']) > 0: - for each in meta['potential_missing']: - if str(meta.get(each, '')).replace(' ', '') in ["", "None", "0"]: - if each == "imdb_id": - each = 'imdb' - missing.append(f"--{each} | {info_notes.get(each)}") - if missing != []: - cli_ui.info_section(cli_ui.yellow, "Potentially missing information:") - for each in missing: - if each.split('|')[0].replace('--', '').strip() in ["imdb"]: - cli_ui.info(cli_ui.red, each) + console.print("[yellow]Changelog not found between versions.[/yellow]") + + return local_version + + +async def do_the_thing(base_dir): + await asyncio.sleep(0.1) # Ensure it's not racing + bot = None + meta = dict() + paths = [] + for each in sys.argv[1:]: + if os.path.exists(each): + paths.append(os.path.abspath(each)) + else: + break + + meta['current_version'] = await update_notification(base_dir) + + cleanup_only = any(arg in ('--cleanup', '-cleanup') for arg in sys.argv) and len(sys.argv) <= 2 + sanitize_meta = config['DEFAULT'].get('sanitize_meta', True) + + try: + # If cleanup is the only operation, use a dummy path to satisfy the parser + if cleanup_only: + args_list = sys.argv[1:] + ['dummy_path'] + meta, help, before_args = parser.parse(tuple(' '.join(args_list).split(' ')), meta) + meta['path'] = None # Clear the dummy path after parsing + else: + meta, help, before_args = parser.parse(tuple(' '.join(sys.argv[1:]).split(' ')), meta) + + if meta.get('cleanup'): + if os.path.exists(f"{base_dir}/tmp"): + shutil.rmtree(f"{base_dir}/tmp") + console.print("[yellow]Successfully emptied tmp directory[/yellow]") + console.print() + if not meta.get('path') or cleanup_only: + exit(0) + + if not meta.get('path'): + exit(0) + + path = meta['path'] + path = os.path.abspath(path) + if path.endswith('"'): + path = path[:-1] + + is_binary = await get_mkbrr_path(meta, base_dir) + if not meta['mkbrr']: + try: + meta['mkbrr'] = int(config['DEFAULT'].get('mkbrr', False)) + except ValueError: + if meta['debug']: + console.print("[yellow]Invalid mkbrr config value, defaulting to False[/yellow]") + meta['mkbrr'] = False + if meta['mkbrr'] and not is_binary: + console.print("[bold red]mkbrr binary is not available. Please ensure it is installed correctly.[/bold red]") + console.print("[bold red]Reverting to Torf[/bold red]") + console.print() + meta['mkbrr'] = False + + queue, log_file = await handle_queue(path, meta, paths, base_dir) + + processed_files_count = 0 + skipped_files_count = 0 + base_meta = {k: v for k, v in meta.items()} + for path in queue: + total_files = len(queue) + try: + meta = base_meta.copy() + meta['path'] = path + meta['uuid'] = None + + if not path: + raise ValueError("The 'path' variable is not defined or is empty.") + + tmp_path = os.path.join(base_dir, "tmp", os.path.basename(path)) + + if meta.get('delete_tmp', False) and os.path.exists(tmp_path): + try: + shutil.rmtree(tmp_path) + os.makedirs(tmp_path, exist_ok=True) + console.print(f"[yellow]Successfully cleaned temp directory for {os.path.basename(path)}[/yellow]") + console.print() + except Exception as e: + console.print(f"[bold red]Failed to delete temp directory: {str(e)}") + + meta_file = os.path.join(base_dir, "tmp", os.path.basename(path), "meta.json") + + keep_meta = config['DEFAULT'].get('keep_meta', False) + + if not keep_meta or meta.get('delete_meta', False): + if os.path.exists(meta_file): + try: + os.remove(meta_file) + if meta['debug']: + console.print(f"[bold yellow]Found and deleted existing metadata file: {meta_file}") + except Exception as e: + console.print(f"[bold red]Failed to delete metadata file {meta_file}: {str(e)}") + else: + if meta['debug']: + console.print(f"[yellow]No metadata file found at {meta_file}") + + if keep_meta and os.path.exists(meta_file): + with open(meta_file, "r") as f: + saved_meta = json.load(f) + console.print("[yellow]Existing metadata file found, it holds cached values") + meta.update(await merge_meta(meta, saved_meta, path)) + + except Exception as e: + console.print(f"[red]Exception: '{path}': {e}") + reset_terminal() + + if use_discord and config['DISCORD'].get('discord_bot_token') and not meta['debug']: + if (config.get('DISCORD', {}).get('only_unattended', False) and meta.get('unattended', False)) or not config.get('DISCORD', {}).get('only_unattended', False): + try: + console.print("[cyan]Starting Discord bot initialization...") + intents = discord.Intents.default() + intents.message_content = True + bot = discord.Client(intents=intents) + token = config['DISCORD']['discord_bot_token'] + await asyncio.wait_for(bot.login(token), timeout=10) + connect_task = asyncio.create_task(bot.connect()) + + try: + await asyncio.wait_for(bot.wait_until_ready(), timeout=20) + console.print("[green]Discord Bot is ready!") + except asyncio.TimeoutError: + console.print("[bold red]Bot failed to connect within timeout period.") + console.print("[yellow]Continuing without Discord integration...") + if 'connect_task' in locals(): + connect_task.cancel() + except discord.LoginFailure: + console.print("[bold red]Discord bot token is invalid. Please check your configuration.") + except discord.ClientException as e: + console.print(f"[bold red]Discord client exception: {e}") + except Exception as e: + console.print(f"[bold red]Unexpected error during Discord bot initialization: {e}") + + if meta['debug']: + start_time = time.time() + + console.print(f"[green]Gathering info for {os.path.basename(path)}") + + await process_meta(meta, base_dir, bot=bot) + + if 'we_are_uploading' not in meta or not meta.get('we_are_uploading', False): + if not meta.get('emby', False): + console.print("we are not uploading.......") + if 'queue' in meta and meta.get('queue') is not None: + processed_files_count += 1 + if not meta.get('emby', False): + skipped_files_count += 1 + console.print(f"[cyan]Processed {processed_files_count}/{total_files} files with {skipped_files_count} skipped uploading.") + else: + console.print(f"[cyan]Processed {processed_files_count}/{total_files}.") + if not meta['debug'] or "debug" in os.path.basename(log_file): + if log_file: + await save_processed_file(log_file, path) + else: - cli_ui.info(each) + console.print() + console.print("[yellow]Processing uploads to trackers.....") + await process_trackers(meta, config, client, console, api_trackers, tracker_class_map, http_trackers, other_api_trackers) + if use_discord and bot: + await send_upload_status_notification(config, bot, meta) + if 'queue' in meta and meta.get('queue') is not None: + processed_files_count += 1 + if 'limit_queue' in meta and int(meta['limit_queue']) > 0: + console.print(f"[cyan]Successfully uploaded {processed_files_count - skipped_files_count} of {meta['limit_queue']} in limit with {total_files} files.") + else: + console.print(f"[cyan]Successfully uploaded {processed_files_count - skipped_files_count}/{total_files} files.") + if not meta['debug'] or "debug" in os.path.basename(log_file): + if log_file: + await save_processed_file(log_file, path) + await asyncio.sleep(0.1) + if sanitize_meta: + try: + await asyncio.sleep(0.2) # We can't race the status prints + meta = await clean_meta_for_export(meta) + except Exception as e: + console.print(f"[red]Error cleaning meta for export: {e}") + await cleanup() + gc.collect() + reset_terminal() + + if meta['debug']: + finish_time = time.time() + console.print(f"Uploads processed in {finish_time - start_time:.4f} seconds") + + if use_discord and bot: + if config['DISCORD'].get('send_upload_links'): + try: + discord_message = "" + for tracker, status in meta.get('tracker_status', {}).items(): + try: + if tracker == "MTV" and 'status_message' in status and "data error" not in str(status['status_message']): + discord_message += f"{str(status['status_message'])}\n" + if 'torrent_id' in status: + tracker_class = tracker_class_map[tracker](config=config) + torrent_url = tracker_class.torrent_url + discord_message += f"{tracker}: {torrent_url}{status['torrent_id']}\n" + else: + if ( + 'status_message' in status + and 'torrent_id' not in status + and "data error" not in str(status['status_message']) + and tracker != "MTV" + ): + discord_message += f"{tracker}: {redact_private_info(status['status_message'])}\n" + elif 'status_message' in status and "data error" in str(status['status_message']): + discord_message += f"{tracker}: {str(status['status_message'])}\n" + else: + if 'skipping' in status and not status['skipping']: + discord_message += f"{tracker} gave no useful message.\n" + except Exception as e: + discord_message += f"Error printing {tracker} data: {e}\n" + discord_message += "All tracker uploads processed.\n" + await send_discord_notification(config, bot, discord_message, debug=meta.get('debug', False), meta=meta) + except Exception as e: + console.print(f"[red]Error in tracker print loop: {e}[/red]") + else: + await send_discord_notification(config, bot, f"Finished uploading: {meta['path']}\n", debug=meta.get('debug', False), meta=meta) + + find_requests = config['DEFAULT'].get('search_requests', False) if meta.get('search_requests') is None else meta.get('search_requests') + if find_requests: + console.print("[green]Searching for requests on supported trackers.....") + tracker_setup = TRACKER_SETUP(config=config) + await tracker_setup.tracker_request(meta, meta['trackers']) + + if sanitize_meta and not meta.get('emby', False): + try: + await asyncio.sleep(0.3) # We can't race the status prints + meta = await clean_meta_for_export(meta) + except Exception as e: + console.print(f"[red]Error cleaning meta for export: {e}") + + if meta.get('delete_tmp', False) and os.path.exists(tmp_path) and meta.get('emby', False): + try: + shutil.rmtree(tmp_path) + console.print(f"[yellow]Successfully deleted temp directory for {os.path.basename(path)}[/yellow]") + console.print() + except Exception as e: + console.print(f"[bold red]Failed to delete temp directory: {str(e)}") - console.print() - return + if 'limit_queue' in meta and int(meta['limit_queue']) > 0: + if (processed_files_count - skipped_files_count) >= int(meta['limit_queue']): + break -if __name__ == '__main__': + except Exception as e: + console.print(f"[bold red]An unexpected error occurred: {e}") + if sanitize_meta: + meta = await clean_meta_for_export(meta) + console.print(traceback.format_exc()) + reset_terminal() + + finally: + if bot is not None: + await bot.close() + if 'connect_task' in locals(): + connect_task.cancel() + try: + await connect_task + except asyncio.CancelledError: + pass + if not sys.stdin.closed: + reset_terminal() + + +async def get_mkbrr_path(meta, base_dir=None): + try: + mkbrr_path = await ensure_mkbrr_binary(base_dir, debug=meta['debug'], version="v1.14.0") + return mkbrr_path + except Exception as e: + console.print(f"[red]Error setting up mkbrr binary: {e}[/red]") + return None + + +def check_python_version(): pyver = platform.python_version_tuple() - if int(pyver[0]) != 3: - console.print("[bold red]Python2 Detected, please use python3") - exit() - else: - if int(pyver[1]) <= 6: - console.print("[bold red]Python <= 3.6 Detected, please use Python >=3.7") - loop = asyncio.get_event_loop() - loop.run_until_complete(do_the_thing(base_dir)) - else: - asyncio.run(do_the_thing(base_dir)) - + if int(pyver[0]) != 3 or int(pyver[1]) < 9: + console.print("[bold red]Python version is too low. Please use Python 3.9 or higher.") + sys.exit(1) + + +async def main(): + try: + await do_the_thing(base_dir) + except asyncio.CancelledError: + console.print("[red]Tasks were cancelled. Exiting safely.[/red]") + except KeyboardInterrupt: + console.print("[bold red]Program interrupted. Exiting safely.[/bold red]") + except Exception as e: + console.print(f"[bold red]Unexpected error: {e}[/bold red]") + finally: + await cleanup() + reset_terminal() + + +if __name__ == "__main__": + check_python_version() + + try: + # Use ProactorEventLoop for Windows subprocess handling + if sys.platform == "win32": + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) + + asyncio.run(main()) # Ensures proper loop handling and cleanup + except (KeyboardInterrupt, SystemExit): + pass + except BaseException as e: + console.print(f"[bold red]Critical error: {e}[/bold red]") + finally: + asyncio.run(cleanup()) + reset_terminal() + sys.exit(0)