diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fef2c00 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* export-ignore \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8dc496c..1091a00 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,11 +14,10 @@ on: - 'v*' jobs: - build-and-publish: + prepare-release: runs-on: ubuntu-latest - permissions: - id-token: write # Required for PyPI trusted publishing - contents: write # Required for creating releases + outputs: + version: ${{ steps.get_version.outputs.version }} steps: - name: Checkout repository uses: actions/checkout@v4 @@ -28,85 +27,58 @@ jobs: - name: Determine version id: get_version run: | - if [[ "${{ github.event_name }}" == "release" ]]; then + if [[ "${{ github.event_name }}" == "push" && "${{ github.ref_type }}" == "tag" ]]; then + VERSION=${GITHUB_REF#refs/tags/v} + elif [[ "${{ github.event_name }}" == "release" ]]; then VERSION=${GITHUB_REF#refs/tags/v} else VERSION=${{ github.event.inputs.version }} fi echo "VERSION=$VERSION" >> $GITHUB_ENV echo "version=$VERSION" >> $GITHUB_OUTPUT + echo "Using version: $VERSION" - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: '3.11' - cache: 'pip' - - - name: Install Poetry and dependencies - run: | - pip install poetry - poetry config virtualenvs.create false - poetry lock - poetry install -q #--quiet (-q) : Do not output any message. + publish-pypi: + needs: prepare-release + uses: ./.github/workflows/publish-pypi.yml + with: + version: ${{ needs.prepare-release.outputs.version }} + secrets: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + PYPI_API_TOKEN: ${{ secrets.PYPI_API_TOKEN }} - - name: Set version in pyproject.toml - run: poetry version ${{ env.VERSION }} + build-windows: + needs: prepare-release + uses: ./.github/workflows/windows-build.yml + with: + version: ${{ needs.prepare-release.outputs.version }} + secrets: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - # This step now injects the secrets directly into the credentials.py file - - name: Inject Simkl Credentials into Code - env: - SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} - SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - run: | - # Check if secrets are available - if [ -z "$SIMKL_CLIENT_ID" ]; then - echo "::error::SIMKL_CLIENT_ID secret is not set." - exit 1 - fi - if [ -z "$SIMKL_CLIENT_SECRET" ]; then - echo "::error::SIMKL_CLIENT_SECRET secret is not set." - exit 1 - fi + build-macos: + needs: prepare-release + uses: ./.github/workflows/macos-build.yml + with: + version: ${{ needs.prepare-release.outputs.version }} + secrets: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - # Inject Client ID - echo "Injecting SIMKL_CLIENT_ID into credentials.py" - # Use a different delimiter for sed in case the secret contains slashes - sed -i "s|SIMKL_CLIENT_ID_PLACEHOLDER|${SIMKL_CLIENT_ID}|g" simkl_mps/credentials.py + build-linux: + needs: prepare-release + uses: ./.github/workflows/linux-build.yml + with: + version: ${{ needs.prepare-release.outputs.version }} + secrets: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} - # Inject Client Secret - echo "Injecting SIMKL_CLIENT_SECRET into credentials.py" - sed -i "s|SIMKL_CLIENT_SECRET_PLACEHOLDER|${SIMKL_CLIENT_SECRET}|g" simkl_mps/credentials.py - - echo "āœ… Credentials successfully injected into simkl_mps/credentials.py" - - - name: Build and publish - run: | - poetry build - poetry publish - env: - POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} - VERSION: ${{ env.VERSION }} - - - name: Upload build artifacts - uses: actions/upload-artifact@v4 - with: - name: dist - path: dist/ - retention-days: 3 - - - name: Create and Upload Release - id: create_release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ env.VERSION }} - name: Release ${{ env.VERSION }} - body: | - ![GitHub Downloads (specific asset, specific tag)](https://img.shields.io/github/downloads/${{ github.repository }}/${{ env.VERSION }}/dist) - append_body: true - generate_release_notes: true - files: | - dist/*.whl - make_latest: true - env: - VERSION: ${{ env.VERSION }} - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + create-github-release: + needs: [prepare-release, publish-pypi, build-windows, build-macos, build-linux] + uses: ./.github/workflows/create-release.yml + with: + version: ${{ needs.prepare-release.outputs.version }} + secrets: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml new file mode 100644 index 0000000..d117bf6 --- /dev/null +++ b/.github/workflows/create-release.yml @@ -0,0 +1,65 @@ +name: Create GitHub Release + +on: + workflow_call: + inputs: + version: + required: true + type: string + secrets: + GITHUB_TOKEN: + required: true + +jobs: + release: + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: release-artifacts + + - name: List downloaded artifacts + run: find release-artifacts -type f | sort + + - name: Create Release + id: create_release + uses: softprops/action-gh-release@v2 + with: + tag_name: v${{ inputs.version }} + name: Release ${{ inputs.version }} + body: | + # MPSS Release ${{ inputs.version }} + + ## Downloads + + ### Windows + - [MPSS_Setup_${{ inputs.version }}.exe](https://github.com/${{ github.repository }}/releases/download/v${{ inputs.version }}/MPSS_Setup_${{ inputs.version }}.exe) + + ### macOS + - [MPSS_macOS.dmg](https://github.com/${{ github.repository }}/releases/download/v${{ inputs.version }}/MPSS_macOS.dmg) (Drag-and-drop installer) + - [MPSS_${{ inputs.version }}.pkg](https://github.com/${{ github.repository }}/releases/download/v${{ inputs.version }}/MPSS_${{ inputs.version }}.pkg) (Package installer) + + ### Linux + - [MPSS_${{ inputs.version }}.AppImage](https://github.com/${{ github.repository }}/releases/download/v${{ inputs.version }}/MPSS_${{ inputs.version }}.AppImage) (Universal AppImage - runs on most distros) + - [MPSS_${{ inputs.version }}_amd64.deb](https://github.com/${{ github.repository }}/releases/download/v${{ inputs.version }}/MPSS_${{ inputs.version }}_amd64.deb) (Debian/Ubuntu package) + + ![GitHub Downloads](https://img.shields.io/github/downloads/${{ github.repository }}/v${{ inputs.version }}/total) + generate_release_notes: true + files: | + release-artifacts/windows-installer/*.exe + release-artifacts/macos-installers/*.dmg + release-artifacts/macos-installers/*.pkg + release-artifacts/linux-installers/*.AppImage + release-artifacts/linux-installers/*.deb + draft: false + prerelease: false + make_latest: true + discussion_category_name: "Releases" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/linux-build.yml b/.github/workflows/linux-build.yml new file mode 100644 index 0000000..ee4b7b6 --- /dev/null +++ b/.github/workflows/linux-build.yml @@ -0,0 +1,299 @@ +name: Build Linux + +on: + workflow_call: + inputs: + version: + required: true + type: string + secrets: + SIMKL_CLIENT_ID: + required: true + SIMKL_CLIENT_SECRET: + required: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry + poetry config virtualenvs.create false + poetry install + + - name: Set version in pyproject.toml + run: poetry version ${{ inputs.version }} + + - name: Inject Simkl Credentials into Code + env: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + run: | + # Check if secrets are available + if [ -z "$SIMKL_CLIENT_ID" ]; then + echo "::error::SIMKL_CLIENT_ID secret is not set." + exit 1 + fi + if [ -z "$SIMKL_CLIENT_SECRET" ]; then + echo "::error::SIMKL_CLIENT_SECRET secret is not set." + exit 1 + fi + + # Inject credentials + sed -i "s|SIMKL_CLIENT_ID_PLACEHOLDER|${SIMKL_CLIENT_ID}|g" simkl_mps/credentials.py + sed -i "s|SIMKL_CLIENT_SECRET_PLACEHOLDER|${SIMKL_CLIENT_SECRET}|g" simkl_mps/credentials.py + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y python3-dev libgtk-3-dev libnotify-dev + + - name: Build with PyInstaller + run: | + pip install pyinstaller + python -m PyInstaller --clean simkl-mps.spec + + # Make sure the updater script is executable + chmod +x simkl_mps/utils/updater.sh + + # Copy and set permissions for updater script in distribution directories + mkdir -p dist/simkl-mps/utils/ + mkdir -p dist/simkl-mps-tray/utils/ + cp simkl_mps/utils/updater.sh dist/simkl-mps/utils/ + cp simkl_mps/utils/updater.sh dist/simkl-mps-tray/utils/ + chmod +x dist/simkl-mps/utils/updater.sh + chmod +x dist/simkl-mps-tray/utils/updater.sh + + # Create systemd user timer for weekly updates + mkdir -p dist/simkl-mps/utils/systemd + cat > dist/simkl-mps/utils/systemd/simkl-mps-updater.service << EOF + [Unit] + Description=SIMKL Media Player Scrobbler Update Check + After=network-online.target + Wants=network-online.target + + [Service] + Type=oneshot + ExecStart=/bin/bash /usr/share/simkl-mps/utils/updater.sh --silent + + [Install] + WantedBy=default.target + EOF + + cat > dist/simkl-mps/utils/systemd/simkl-mps-updater.timer << EOF + [Unit] + Description=Weekly update check for SIMKL Media Player Scrobbler + + [Timer] + OnCalendar=Mon *-*-* 12:00:00 + Persistent=true + + [Install] + WantedBy=timers.target + EOF + + # Create installation script for Linux + cat > dist/simkl-mps/utils/setup-auto-update.sh << EOF + #!/bin/bash + + # Create systemd user directory if it doesn't exist + mkdir -p ~/.config/systemd/user/ + + # Copy service and timer files + cp /usr/share/simkl-mps/utils/systemd/simkl-mps-updater.service ~/.config/systemd/user/ + cp /usr/share/simkl-mps/utils/systemd/simkl-mps-updater.timer ~/.config/systemd/user/ + + # Enable and start the timer + systemctl --user daemon-reload + systemctl --user enable simkl-mps-updater.timer + systemctl --user start simkl-mps-updater.timer + + # Set first run flag + mkdir -p ~/.config/simkl-mps/ + touch ~/.config/simkl-mps/first_run + + echo "Auto-updates have been configured to run weekly." + EOF + chmod +x dist/simkl-mps/utils/setup-auto-update.sh + + # Copy these files to the tray app directory too + mkdir -p dist/simkl-mps-tray/utils/systemd + cp dist/simkl-mps/utils/systemd/simkl-mps-updater.service dist/simkl-mps-tray/utils/systemd/ + cp dist/simkl-mps/utils/systemd/simkl-mps-updater.timer dist/simkl-mps-tray/utils/systemd/ + cp dist/simkl-mps/utils/setup-auto-update.sh dist/simkl-mps-tray/utils/ + + # Create the tarball including the updater script + cd dist + tar -czvf MPSS_Linux.tar.gz simkl-mps simkl-mps-tray + cd .. + + - name: Test PyInstaller build + run: | + python test_build.py linux + + - name: Package AppImage + run: | + VERSION="${{ inputs.version }}" + mkdir -p dist/AppDir + cp -r dist/simkl-mps/* dist/AppDir/ + pip install appimagetool + # Create basic desktop file + mkdir -p dist/AppDir/usr/share/applications + cat > dist/AppDir/usr/share/applications/simkl-mps.desktop << EOF + [Desktop Entry] + Name=MPSS + Exec=simkl-mps + Icon=simkl-mps + Type=Application + Categories=Utility; + EOF + # Copy icon + mkdir -p dist/AppDir/usr/share/icons + cp simkl_mps/assets/simkl-mps.png dist/AppDir/usr/share/icons/ + # Create AppImage + appimagetool dist/AppDir dist/MPSS_${VERSION}.AppImage + + - name: Package Debian (.deb) + run: | + VERSION="${{ inputs.version }}" + # Create Debian package directory structure + mkdir -p dist/deb/MPSS_${VERSION}/usr/bin + mkdir -p dist/deb/MPSS_${VERSION}/usr/share/applications + mkdir -p dist/deb/MPSS_${VERSION}/usr/share/pixmaps + mkdir -p dist/deb/MPSS_${VERSION}/usr/share/simkl-mps/utils/systemd + mkdir -p dist/deb/MPSS_${VERSION}/etc/xdg/autostart + mkdir -p dist/deb/MPSS_${VERSION}/DEBIAN + + # Copy application files + cp -r dist/simkl-mps/* dist/deb/MPSS_${VERSION}/usr/bin/ + + # Copy systemd files for auto-updates + cp dist/simkl-mps/utils/systemd/simkl-mps-updater.service dist/deb/MPSS_${VERSION}/usr/share/simkl-mps/utils/systemd/ + cp dist/simkl-mps/utils/systemd/simkl-mps-updater.timer dist/deb/MPSS_${VERSION}/usr/share/simkl-mps/utils/systemd/ + cp dist/simkl-mps/utils/setup-auto-update.sh dist/deb/MPSS_${VERSION}/usr/share/simkl-mps/utils/ + cp dist/simkl-mps/utils/updater.sh dist/deb/MPSS_${VERSION}/usr/share/simkl-mps/utils/ + chmod +x dist/deb/MPSS_${VERSION}/usr/share/simkl-mps/utils/setup-auto-update.sh + chmod +x dist/deb/MPSS_${VERSION}/usr/share/simkl-mps/utils/updater.sh + + # Create symlink for main executable + chmod +x dist/deb/MPSS_${VERSION}/usr/bin/simkl-mps + + # Copy desktop file + cat > dist/deb/MPSS_${VERSION}/usr/share/applications/simkl-mps.desktop << EOF + [Desktop Entry] + Name=Media Player Scrobbler for SIMKL + Exec=/usr/bin/simkl-mps start + Icon=simkl-mps + Type=Application + Categories=Utility; + Comment=Automatically scrobble movies and TV shows to SIMKL + EOF + + # Create autostart desktop file + cat > dist/deb/MPSS_${VERSION}/etc/xdg/autostart/simkl-mps.desktop << EOF + [Desktop Entry] + Name=Media Player Scrobbler for SIMKL + Exec=/usr/bin/simkl-mps start + Icon=simkl-mps + Type=Application + X-GNOME-Autostart-enabled=true + NoDisplay=false + Comment=Automatically scrobble movies and TV shows to SIMKL + EOF + + # Copy icon + cp simkl_mps/assets/simkl-mps.png dist/deb/MPSS_${VERSION}/usr/share/pixmaps/simkl-mps.png + + # Create control file + cat > dist/deb/MPSS_${VERSION}/DEBIAN/control << EOF + Package: simkl-mps + Version: ${VERSION} + Section: utils + Priority: optional + Architecture: amd64 + Depends: python3 (>= 3.9), libgtk-3-0, libnotify4 + Maintainer: kavinthangavel + Description: Media Player Scrobbler for SIMKL + Automatically scrobble your media activity to SIMKL from various media players. + Supports VLC, MPV, MPC-HC, and PotPlayer. + EOF + + # Create postinst script + cat > dist/deb/MPSS_${VERSION}/DEBIAN/postinst << EOF + #!/bin/sh + set -e + + # Make sure the executable has proper permissions + chmod +x /usr/bin/simkl-mps + + # Update desktop database + if [ -x /usr/bin/update-desktop-database ]; then + /usr/bin/update-desktop-database -q + fi + + # Set up auto-updates for the current user + user=\$(who | awk '{print \$1}' | head -1) + if [ -n "\$user" ]; then + uid=\$(id -u \$user) + home=\$(getent passwd \$user | cut -d: -f6) + + # Create systemd user directory + mkdir -p \$home/.config/systemd/user/ + cp /usr/share/simkl-mps/utils/systemd/simkl-mps-updater.service \$home/.config/systemd/user/ + cp /usr/share/simkl-mps/utils/systemd/simkl-mps-updater.timer \$home/.config/systemd/user/ + chown -R \$user:\$user \$home/.config/systemd/ + + # Prompt user about auto-updates (uses zenity if available) + if command -v zenity >/dev/null 2>&1; then + DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$uid/bus su -c "zenity --question --title='SIMKL Media Player Scrobbler' --text='Would you like to enable weekly automatic update checks?' --ok-label='Yes' --cancel-label='No'" \$user + if [ \$? -eq 0 ]; then + DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$uid/bus su -c "systemctl --user daemon-reload && systemctl --user enable simkl-mps-updater.timer && systemctl --user start simkl-mps-updater.timer" \$user + fi + else + # Without zenity, just set it up and inform in terminal + DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$uid/bus su -c "systemctl --user daemon-reload && systemctl --user enable simkl-mps-updater.timer && systemctl --user start simkl-mps-updater.timer" \$user + echo "Weekly update checks have been enabled. You can disable them with: systemctl --user disable simkl-mps-updater.timer" + fi + + # Set first run flag + mkdir -p \$home/.config/simkl-mps/ + touch \$home/.config/simkl-mps/first_run + chown -R \$user:\$user \$home/.config/simkl-mps/ + fi + + # Start the application for the current user + user=\$(who | awk '{print \$1}' | head -1) + if [ -n "\$user" ]; then + uid=\$(id -u \$user) + DISPLAY=:0 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/\$uid/bus su -c "/usr/bin/simkl-mps start" \$user & + fi + + exit 0 + EOF + chmod 755 dist/deb/MPSS_${VERSION}/DEBIAN/postinst + + # Build the deb package + cd dist/deb + fakeroot dpkg-deb --build MPSS_${VERSION} + mv MPSS_${VERSION}.deb ../MPSS_${VERSION}_amd64.deb + cd ../.. + + - name: Upload Linux artifacts + uses: actions/upload-artifact@v4 + with: + name: linux-installers + path: | + dist/MPSS_*.AppImage + dist/MPSS_*_amd64.deb + retention-days: 3 diff --git a/.github/workflows/macos-build.yml b/.github/workflows/macos-build.yml new file mode 100644 index 0000000..55d5301 --- /dev/null +++ b/.github/workflows/macos-build.yml @@ -0,0 +1,346 @@ +name: Build macOS + +on: + workflow_call: + inputs: + version: + required: true + type: string + secrets: + SIMKL_CLIENT_ID: + required: true + SIMKL_CLIENT_SECRET: + required: true + +jobs: + build: + runs-on: macos-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry pillow + poetry config virtualenvs.create false + poetry install + + - name: Set version in pyproject.toml + run: poetry version ${{ inputs.version }} + + - name: Generate macOS icon (.icns file) + run: | + # Create a basic .iconset structure + mkdir -p simkl-mps.iconset + + # Create necessary icons if not exist + for size in 16 32 64 128 256 512 1024; do + if [ ! -f "simkl_mps/assets/simkl-mps-$size.png" ] && [ -f "simkl_mps/assets/simkl-mps.png" ]; then + # If size-specific icon doesn't exist but base icon does, resize it + sips -z $size $size simkl_mps/assets/simkl-mps.png --out simkl_mps/assets/simkl-mps-$size.png + fi + done + + # Convert existing PNG icons to iconset format + if [ -f "simkl_mps/assets/simkl-mps-16.png" ]; then + cp simkl_mps/assets/simkl-mps-16.png simkl-mps.iconset/icon_16x16.png + fi + + if [ -f "simkl_mps/assets/simkl-mps-32.png" ]; then + cp simkl_mps/assets/simkl-mps-32.png simkl-mps.iconset/icon_16x16@2x.png + cp simkl_mps/assets/simkl-mps-32.png simkl-mps.iconset/icon_32x32.png + fi + + if [ -f "simkl_mps/assets/simkl-mps-64.png" ]; then + cp simkl_mps/assets/simkl-mps-64.png simkl-mps.iconset/icon_32x32@2x.png + fi + + if [ -f "simkl_mps/assets/simkl-mps-128.png" ]; then + cp simkl_mps/assets/simkl-mps-128.png simkl-mps.iconset/icon_128x128.png + fi + + if [ -f "simkl_mps/assets/simkl-mps-256.png" ]; then + cp simkl_mps/assets/simkl-mps-256.png simkl-mps.iconset/icon_128x128@2x.png + cp simkl_mps/assets/simkl-mps-256.png simkl-mps.iconset/icon_256x256.png + fi + + if [ -f "simkl_mps/assets/simkl-mps-512.png" ]; then + cp simkl_mps/assets/simkl-mps-512.png simkl-mps.iconset/icon_256x256@2x.png + cp simkl_mps/assets/simkl-mps-512.png simkl-mps.iconset/icon_512x512.png + fi + + if [ -f "simkl_mps/assets/simkl-mps-1024.png" ]; then + cp simkl_mps/assets/simkl-mps-1024.png simkl-mps.iconset/icon_512x512@2x.png + fi + + # Fill in any missing icons with resized versions + for size in 16 32 64 128 256 512; do + if [ ! -f "simkl-mps.iconset/icon_${size}x${size}.png" ]; then + # Find the nearest larger icon + found=false + for src_size in 1024 512 256 128 64 32 16; do + if [ $src_size -ge $size ] && [ -f "simkl_mps/assets/simkl-mps-${src_size}.png" ]; then + sips -z $size $size "simkl_mps/assets/simkl-mps-${src_size}.png" --out "simkl-mps.iconset/icon_${size}x${size}.png" + found=true + break + fi + done + + # If still not found, use the base icon + if [ "$found" = false ] && [ -f "simkl_mps/assets/simkl-mps.png" ]; then + sips -z $size $size "simkl_mps/assets/simkl-mps.png" --out "simkl-mps.iconset/icon_${size}x${size}.png" + fi + fi + + # Create @2x version if missing + if [ ! -f "simkl-mps.iconset/icon_${size}x${size}@2x.png" ] && [ $((size*2)) -le 1024 ]; then + doubled_size=$((size*2)) + # Find the nearest larger icon + found=false + for src_size in 1024 512 256 128 64 32; do + if [ $src_size -ge $doubled_size ] && [ -f "simkl_mps/assets/simkl-mps-${src_size}.png" ]; then + sips -z $doubled_size $doubled_size "simkl_mps/assets/simkl-mps-${src_size}.png" --out "simkl-mps.iconset/icon_${size}x${size}@2x.png" + found=true + break + fi + done + + # If still not found, use the base icon + if [ "$found" = false ] && [ -f "simkl_mps/assets/simkl-mps.png" ]; then + sips -z $doubled_size $doubled_size "simkl_mps/assets/simkl-mps.png" --out "simkl-mps.iconset/icon_${size}x${size}@2x.png" + fi + fi + done + + # Generate .icns file using iconutil + iconutil -c icns simkl-mps.iconset -o simkl_mps/assets/simkl-mps.icns + + # Clean up + rm -rf simkl-mps.iconset + + echo "āœ… macOS icon (.icns) created successfully" + ls -la simkl_mps/assets/simkl-mps.icns + + - name: Inject Simkl Credentials into Code + env: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + run: | + # Check if secrets are available + if [ -z "$SIMKL_CLIENT_ID" ]; then + echo "::error::SIMKL_CLIENT_ID secret is not set." + exit 1 + fi + if [ -z "$SIMKL_CLIENT_SECRET" ]; then + echo "::error::SIMKL_CLIENT_SECRET secret is not set." + exit 1 + fi + + # Inject credentials + sed -i '' "s|SIMKL_CLIENT_ID_PLACEHOLDER|${SIMKL_CLIENT_ID}|g" simkl_mps/credentials.py + sed -i '' "s|SIMKL_CLIENT_SECRET_PLACEHOLDER|${SIMKL_CLIENT_SECRET}|g" simkl_mps/credentials.py + + - name: Build with PyInstaller + run: | + pip install pyinstaller + python -m PyInstaller --clean simkl-mps.spec + + # Make sure the updater script is executable + chmod +x simkl_mps/utils/updater.sh + + # Copy and set permissions for updater script in app bundles + mkdir -p "dist/MPSS.app/Contents/MacOS/utils/" + mkdir -p "dist/MPS for Simkl.app/Contents/MacOS/utils/" + cp simkl_mps/utils/updater.sh "dist/MPSS.app/Contents/MacOS/utils/" + cp simkl_mps/utils/updater.sh "dist/MPS for Simkl.app/Contents/MacOS/utils/" + chmod +x "dist/MPSS.app/Contents/MacOS/utils/updater.sh" + chmod +x "dist/MPS for Simkl.app/Contents/MacOS/utils/updater.sh" + + # Create LaunchAgent for weekly update checks + mkdir -p dist/MPSS.app/Contents/Resources/ + cat > dist/MPSS.app/Contents/Resources/com.simkl.mpss.updater.plist << EOF + + + + + Label + com.simkl.mpss.updater + ProgramArguments + + bash + /Applications/MPSS.app/Contents/MacOS/utils/updater.sh + --silent + + StartCalendarInterval + + Weekday + 1 + Hour + 12 + Minute + 0 + + RunAtLoad + + StandardErrorPath + /tmp/com.simkl.mpss.updater.err + StandardOutPath + /tmp/com.simkl.mpss.updater.out + + + EOF + + # Add postinstall script to set up LaunchAgent + mkdir -p dist/MPSS.app/Contents/Resources/scripts/ + cat > dist/MPSS.app/Contents/Resources/scripts/postinstall.sh << EOF + #!/bin/bash + + # Create LaunchAgent directory if needed + mkdir -p ~/Library/LaunchAgents/ + + # Copy LaunchAgent plist + cp /Applications/MPSS.app/Contents/Resources/com.simkl.mpss.updater.plist ~/Library/LaunchAgents/ + + # Load the LaunchAgent + launchctl load ~/Library/LaunchAgents/com.simkl.mpss.updater.plist + + # Set first run flag to show welcome message + mkdir -p ~/.config/simkl-mps/ + touch ~/.config/simkl-mps/first_run + + exit 0 + EOF + chmod +x dist/MPSS.app/Contents/Resources/scripts/postinstall.sh + + - name: Test PyInstaller build + run: | + python test_build.py macos + + - name: Create DMG + run: | + VERSION="${{ inputs.version }}" + + # Set up DMG creation + brew install create-dmg || true + + # Create a staging area for the DMG + mkdir -p staging + cp -r "dist/MPSS.app" staging/ + + # Ensure the updater script is included and executable + mkdir -p "staging/MPSS.app/Contents/MacOS/utils/" + cp simkl_mps/utils/updater.sh "staging/MPSS.app/Contents/MacOS/utils/" + chmod +x "staging/MPSS.app/Contents/MacOS/utils/updater.sh" + + # Ensure the LaunchAgent files are included + mkdir -p "staging/MPSS.app/Contents/Resources/" + cp dist/MPSS.app/Contents/Resources/com.simkl.mpss.updater.plist "staging/MPSS.app/Contents/Resources/" + mkdir -p "staging/MPSS.app/Contents/Resources/scripts/" + cp dist/MPSS.app/Contents/Resources/scripts/postinstall.sh "staging/MPSS.app/Contents/Resources/scripts/" + + # Create the DMG + create-dmg \ + --volname "MPSS Installer" \ + --volicon "simkl_mps/assets/simkl-mps.icns" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "MPSS.app" 200 190 \ + --hide-extension "MPSS.app" \ + --app-drop-link 600 185 \ + "dist/MPSS_macOS.dmg" \ + "staging/" || true + + # If create-dmg fails, create a simpler DMG + if [ ! -f "dist/MPSS_macOS.dmg" ]; then + hdiutil create -volname "MPSS Installer" -srcfolder staging -ov -format UDZO "dist/MPSS_macOS.dmg" + fi + + - name: Create macOS PKG Installer + run: | + VERSION="${{ inputs.version }}" + + # Create directory structure for PKG + mkdir -p dist/pkg_build/payload/Applications + cp -r "dist/MPSS.app" "dist/pkg_build/payload/Applications/" + + # Create a scripts directory for pre/post install scripts + mkdir -p dist/pkg_build/scripts + + # Create a postinstall script that ensures proper permissions and starts the app + cat > dist/pkg_build/scripts/postinstall << EOF + #!/bin/bash + # Ensure proper permissions for the app + chmod -R 755 /Applications/MPSS.app + + # Set up LaunchAgent for weekly updates + mkdir -p /Users/\$USER/Library/LaunchAgents/ + cp /Applications/MPSS.app/Contents/Resources/com.simkl.mpss.updater.plist /Users/\$USER/Library/LaunchAgents/ + chown \$USER:staff /Users/\$USER/Library/LaunchAgents/com.simkl.mpss.updater.plist + sudo -u \$USER launchctl load /Users/\$USER/Library/LaunchAgents/com.simkl.mpss.updater.plist + + # Start the application automatically + sudo -u \$USER open /Applications/MPSS.app --args start + + # Add to login items for the current user + osascript -e 'tell application "System Events" to make login item at end with properties {path:"/Applications/MPSS.app", hidden:false}' + + # Set first run flag to show welcome message + mkdir -p /Users/\$USER/.config/simkl-mps/ + touch /Users/\$USER/.config/simkl-mps/first_run + chown -R \$USER:staff /Users/\$USER/.config/simkl-mps/ + + exit 0 + EOF + chmod +x dist/pkg_build/scripts/postinstall + + # Create the component package + pkgbuild --root dist/pkg_build/payload \ + --identifier "com.simkl.mpss" \ + --version "$VERSION" \ + --scripts dist/pkg_build/scripts \ + "dist/pkg_build/MPSS-component.pkg" + + # Create a distribution file + cat > dist/pkg_build/distribution.xml << EOF + + + Media Player Scrobbler for SIMKL + com.simkl + + + + + + + + + + + + + MPSS-component.pkg + + EOF + + # Build the final installer package + productbuild --distribution dist/pkg_build/distribution.xml \ + --package-path dist/pkg_build \ + --version "$VERSION" \ + "dist/MPSS_${VERSION}.pkg" + + - name: Upload macOS artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-installers + path: | + dist/MPSS_*.dmg + dist/MPSS_*.pkg + retention-days: 3 diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml new file mode 100644 index 0000000..72896c5 --- /dev/null +++ b/.github/workflows/publish-pypi.yml @@ -0,0 +1,82 @@ +name: Publish to PyPI + +on: + workflow_call: + inputs: + version: + required: true + type: string + secrets: + SIMKL_CLIENT_ID: + required: true + SIMKL_CLIENT_SECRET: + required: true + PYPI_API_TOKEN: + required: true + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + id-token: write # Required for PyPI trusted publishing + contents: write # Required for creating releases + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install Poetry and dependencies + run: | + pip install poetry + poetry config virtualenvs.create false + poetry install + + - name: Set version in pyproject.toml + run: poetry version ${{ inputs.version }} + + # This step injects the secrets directly into the credentials.py file + - name: Inject Simkl Credentials into Code + env: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + run: | + # Check if secrets are available + if [ -z "$SIMKL_CLIENT_ID" ]; then + echo "::error::SIMKL_CLIENT_ID secret is not set." + exit 1 + fi + if [ -z "$SIMKL_CLIENT_SECRET" ]; then + echo "::error::SIMKL_CLIENT_SECRET secret is not set." + exit 1 + fi + + # Inject Client ID + echo "Injecting SIMKL_CLIENT_ID into credentials.py" + # Use a different delimiter for sed in case the secret contains slashes + sed -i "s|SIMKL_CLIENT_ID_PLACEHOLDER|${SIMKL_CLIENT_ID}|g" simkl_mps/credentials.py + + # Inject Client Secret + echo "Injecting SIMKL_CLIENT_SECRET into credentials.py" + sed -i "s|SIMKL_CLIENT_SECRET_PLACEHOLDER|${SIMKL_CLIENT_SECRET}|g" simkl_mps/credentials.py + + echo "āœ… Credentials successfully injected into simkl_mps/credentials.py" + + - name: Build and publish + run: | + poetry build + poetry publish + env: + POETRY_PYPI_TOKEN_PYPI: ${{ secrets.PYPI_API_TOKEN }} + VERSION: ${{ inputs.version }} + + - name: Upload Python package artifacts + uses: actions/upload-artifact@v4 + with: + name: python-package + path: dist/ + retention-days: 3 diff --git a/.github/workflows/windows-build.yml b/.github/workflows/windows-build.yml new file mode 100644 index 0000000..2cf4609 --- /dev/null +++ b/.github/workflows/windows-build.yml @@ -0,0 +1,89 @@ +name: Build Windows + +on: + workflow_call: + inputs: + version: + required: true + type: string + secrets: + SIMKL_CLIENT_ID: + required: true + SIMKL_CLIENT_SECRET: + required: true + +jobs: + build: + runs-on: windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install poetry + poetry config virtualenvs.create false + poetry install + + - name: Set version in pyproject.toml + run: poetry version ${{ inputs.version }} + + - name: Inject Simkl Credentials into Code + env: + SIMKL_CLIENT_ID: ${{ secrets.SIMKL_CLIENT_ID }} + SIMKL_CLIENT_SECRET: ${{ secrets.SIMKL_CLIENT_SECRET }} + shell: bash + run: | + # Check if secrets are available + if [ -z "$SIMKL_CLIENT_ID" ]; then + echo "::error::SIMKL_CLIENT_ID secret is not set." + exit 1 + fi + if [ -z "$SIMKL_CLIENT_SECRET" ]; then + echo "::error::SIMKL_CLIENT_SECRET secret is not set." + exit 1 + fi + + # Inject Client ID and Secret + sed -i "s|SIMKL_CLIENT_ID_PLACEHOLDER|${SIMKL_CLIENT_ID}|g" simkl_mps/credentials.py + sed -i "s|SIMKL_CLIENT_SECRET_PLACEHOLDER|${SIMKL_CLIENT_SECRET}|g" simkl_mps/credentials.py + + - name: Install Inno Setup + run: | + choco install innosetup -y + + - name: Build with PyInstaller + run: | + pip install pyinstaller + python -m PyInstaller --clean simkl-mps.spec + + - name: Test PyInstaller build + shell: bash + run: | + python test_build.py windows + + - name: Build Installer with Inno Setup + run: | + $version = "${{ inputs.version }}" + (Get-Content setup.iss) -replace '#define MyAppVersion "[^"]*"', "#define MyAppVersion `"$version`"" | Set-Content setup_temp.iss + & 'C:\Program Files (x86)\Inno Setup 6\ISCC.exe' /Q setup_temp.iss + + - name: Rename and move installer + run: | + $version = "${{ inputs.version }}" + mkdir -p artifacts + Move-Item dist\installer\MPSS_Setup.exe artifacts\MPSS_Setup_$version.exe + + - name: Upload Windows artifacts + uses: actions/upload-artifact@v4 + with: + name: windows-installer + path: artifacts/ + retention-days: 3 diff --git a/build_installer.bat b/build_installer.bat new file mode 100644 index 0000000..a14f5fb --- /dev/null +++ b/build_installer.bat @@ -0,0 +1,103 @@ +@echo off +setlocal enabledelayedexpansion + +echo =================================================== +echo Building Media Player Scrobbler for SIMKL Installer +echo =================================================== +echo. + +REM Check if Python is available +python --version >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Python not found in PATH + exit /b 1 +) + +REM Check if PyInstaller is available +python -c "import PyInstaller" >nul 2>&1 +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: PyInstaller not found. Please install it using: + echo pip install pyinstaller + exit /b 1 +) + +REM Create output directory for installer +if not exist "dist\installer" mkdir "dist\installer" + +echo Step 1: Get version from pyproject.toml +echo ----------------------------- +for /f %%V in ('python get_version.py') do set APP_VERSION=%%V +echo Detected version: %APP_VERSION% + +echo. +echo Step 2: Clean previous builds +echo ----------------------------- +if exist "build" rmdir /s /q "build" +if exist "dist" rmdir /s /q "dist" +mkdir "dist" +mkdir "dist\installer" + +echo. +echo Step 3: Build executables with PyInstaller +echo ----------------------------------------- +python -m PyInstaller --clean simkl-mps.spec +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: PyInstaller build failed + exit /b %ERRORLEVEL% +) + +echo. +echo Step 4: Generate Inno Setup script with correct version +echo ------------------------------------ +echo Generating setup script with version %APP_VERSION%... + +REM Create a temp copy of the setup script with the correct version +type setup.iss | powershell -Command "$input | ForEach-Object { $_ -replace '#define MyAppVersion \"[^\"]*\"', '#define MyAppVersion \"%APP_VERSION%\"' }" > setup_temp.iss + +echo. +echo Step 5: Building Inno Setup installer +echo ------------------------------------ +REM Check if Inno Setup compiler (ISCC) is in PATH or in common locations +set "ISCC_PATH=" +where iscc >nul 2>&1 +if %ERRORLEVEL% EQU 0 ( + set "ISCC_PATH=iscc" +) else ( + for %%I in ( + "%ProgramFiles(x86)%\Inno Setup 6\ISCC.exe" + "%ProgramFiles%\Inno Setup 6\ISCC.exe" + "%ProgramFiles(x86)%\Inno Setup 5\ISCC.exe" + "%ProgramFiles%\Inno Setup 5\ISCC.exe" + ) do ( + if exist "%%~I" ( + set "ISCC_PATH=%%~I" + goto :found_iscc + ) + ) + + echo ERROR: Inno Setup compiler (ISCC) not found. + echo Please install Inno Setup from https://jrsoftware.org/isinfo.php + echo or add its directory to your PATH + exit /b 1 +) + +:found_iscc +echo Using Inno Setup compiler: !ISCC_PATH! +"!ISCC_PATH!" /Q setup_temp.iss +if %ERRORLEVEL% NEQ 0 ( + echo ERROR: Inno Setup compilation failed + exit /b %ERRORLEVEL% +) + +REM Clean up the temporary setup file +del setup_temp.iss + +echo. +echo =================================================== +echo Build completed successfully! +echo =================================================== +echo. +echo Installer created: dist\installer\MPSS_Setup_%APP_VERSION%.exe +echo. + +exit /b 0 \ No newline at end of file diff --git a/create_icns.py b/create_icns.py new file mode 100644 index 0000000..79d4a5b --- /dev/null +++ b/create_icns.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Create macOS .icns file from PNG images for simkl-mps + +This script converts PNG images of different sizes into a macOS .icns file +which is required for proper app bundling on macOS. + +Requirements: +- PIL/Pillow library +- macOS (for iconutil) OR any OS with png2icns tool installed + +Usage: + python create_icns.py + +The script looks for PNG files in the simkl_mps/assets folder with specific +sizes (16, 32, 64, 128, 256, 512, 1024) and creates an .icns file. +""" + +import os +import sys +import subprocess +import shutil +from pathlib import Path +from PIL import Image + +# Define required icon sizes for macOS +REQUIRED_SIZES = [16, 32, 64, 128, 256, 512, 1024] + +def find_asset_folder(): + """Find the assets folder""" + script_dir = Path(__file__).resolve().parent + asset_folder = script_dir / "simkl_mps" / "assets" + + if not asset_folder.exists(): + print(f"Error: Assets folder not found at {asset_folder}") + sys.exit(1) + + return asset_folder + +def find_source_icon(asset_folder): + """Find the highest resolution source icon""" + # Look for the highest resolution existing icon + source_icon = None + highest_resolution = 0 + + # Check for existing png files + for size in sorted(REQUIRED_SIZES, reverse=True): + # Check specific size pattern + icon_path = asset_folder / f"simkl-mps-{size}.png" + if icon_path.exists(): + source_icon = icon_path + highest_resolution = size + break + + # If no size-specific icons found, try the base icon + if source_icon is None: + icon_path = asset_folder / "simkl-mps.png" + if icon_path.exists(): + source_icon = icon_path + # Open image to get its size + with Image.open(icon_path) as img: + highest_resolution = min(img.width, img.height) + + if source_icon is None: + print("Error: Could not find any suitable source icon") + sys.exit(1) + + print(f"Using source icon: {source_icon} ({highest_resolution}x{highest_resolution})") + return source_icon, highest_resolution + +def create_iconset_folder(asset_folder): + """Create a temporary .iconset folder""" + iconset_folder = asset_folder / "simkl-mps.iconset" + + # Clear the folder if it already exists + if iconset_folder.exists(): + shutil.rmtree(iconset_folder) + + iconset_folder.mkdir(exist_ok=True) + return iconset_folder + +def generate_icns_mac(asset_folder, iconset_folder): + """Generate .icns using macOS native iconutil tool""" + try: + subprocess.run([ + "iconutil", + "-c", "icns", + str(iconset_folder), + "-o", str(asset_folder / "simkl-mps.icns") + ], check=True) + print(f"Successfully created simkl-mps.icns at {asset_folder}") + return True + except subprocess.CalledProcessError as e: + print(f"Error running iconutil: {e}") + return False + except FileNotFoundError: + print("iconutil command not found. Are you running on macOS?") + return False + +def convert_with_pillow(source_icon, iconset_folder): + """Convert source icon to all required sizes using Pillow""" + with Image.open(source_icon) as img: + # Ensure the image is square + width, height = img.size + if width != height: + # Crop to square using the smaller dimension + size = min(width, height) + left = (width - size) // 2 + top = (height - size) // 2 + right = left + size + bottom = top + size + img = img.crop((left, top, right, bottom)) + + # Generate all required sizes + for size in REQUIRED_SIZES: + # For normal resolution + resized_img = img.resize((size, size), Image.LANCZOS) + normal_filename = f"icon_{size}x{size}.png" + resized_img.save(iconset_folder / normal_filename) + + # For Retina display (2x) + if size * 2 <= max(REQUIRED_SIZES): + retina_size = size * 2 + retina_filename = f"icon_{size}x{size}@2x.png" + retina_img = img.resize((retina_size, retina_size), Image.LANCZOS) + retina_img.save(iconset_folder / retina_filename) + +def main(): + # Find assets folder + asset_folder = find_asset_folder() + + # Find source icon + source_icon, highest_resolution = find_source_icon(asset_folder) + + # Create iconset folder + iconset_folder = create_iconset_folder(asset_folder) + + # Convert source icon to all required sizes + convert_with_pillow(source_icon, iconset_folder) + + # Try to generate icns using macOS native tool + success = False + if sys.platform == 'darwin': + success = generate_icns_mac(asset_folder, iconset_folder) + + # If macOS tool failed or not available, try alternative methods + if not success: + try: + # Try png2icns if available + subprocess.run([ + "png2icns", + str(asset_folder / "simkl-mps.icns"), + *[str(iconset_folder / f"icon_{size}x{size}.png") for size in REQUIRED_SIZES] + ], check=True) + print(f"Successfully created simkl-mps.icns at {asset_folder} using png2icns") + success = True + except (subprocess.CalledProcessError, FileNotFoundError): + print("png2icns not available or failed") + + # Clean up + if iconset_folder.exists(): + shutil.rmtree(iconset_folder) + + if not success: + print("\nWarning: Could not create .icns file directly.") + print("For macOS builds, you'll need to manually create the .icns file.") + print("Consider running this script on a macOS system, or use a tool like:") + print("- https://iconverticons.com/online/") + print("- https://img2icnsapp.com/") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/get_version.py b/get_version.py new file mode 100644 index 0000000..1987c00 --- /dev/null +++ b/get_version.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +""" +Extract version number from pyproject.toml for automated version management. +Used by build scripts to ensure version consistency across the project. +""" + +import re +import sys + +def get_version_from_pyproject(): + """Extract version from pyproject.toml file.""" + try: + with open('pyproject.toml', 'r') as f: + content = f.read() + + # Find the version using regex + match = re.search(r'version\s*=\s*"([^"]+)"', content) + if match: + return match.group(1) + else: + print("Error: Could not find version in pyproject.toml", file=sys.stderr) + return "0.0.0" # Default fallback version + except Exception as e: + print(f"Error reading pyproject.toml: {e}", file=sys.stderr) + return "0.0.0" # Default fallback version + +if __name__ == "__main__": + # Print the version to stdout so it can be captured by build scripts + print(get_version_from_pyproject()) \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 4b6b533..a1a9354 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ keywords = ["simkl", "scrobbler"] homepage = "https://github.com/kavinthangavel/media-player-scrobbler-for-simkl" repository = "https://github.com/kavinthangavel/media-player-scrobbler-for-simkl" classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Stable", "Intended Audience :: End Users/Desktop", "Operating System :: OS Independent", "License :: OSI Approved :: GNU General Public License v3 (GPLv3)", diff --git a/setup.iss b/setup.iss new file mode 100644 index 0000000..85b9ab8 --- /dev/null +++ b/setup.iss @@ -0,0 +1,336 @@ +#define MyAppName "Media Player Scrobbler for SIMKL" +#define MyAppShortName "MPS for SIMKL" +#define MyAppPublisher "kavinthangavel" +#define MyAppURL "https://github.com/kavinthangavel/simkl-movie-tracker" +#define MyAppExeName "MPSS" +#define MyAppTrayName "MPS for Simkl" +#define MyAppVersion "1.0.0" +#define MyAppDescription "Automatically track and scrobble media you watch to SIMKL" +#define MyAppCopyright "Copyright (C) 2025 kavinthangavel" +#define MyAppUpdateURL "https://github.com/kavinthangavel/simkl-movie-tracker/releases" +#define MyAppReadmeURL "https://github.com/kavinthangavel/simkl-movie-tracker#readme" +#define MyAppIssuesURL "https://github.com/kavinthangavel/simkl-movie-tracker/issues" +#define MyLicense "GNU GPL v3" + +[Setup] +; NOTE: The value of AppId uniquely identifies this application. +; Do not use the same AppId value in installers for other applications. +AppId={{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppVerName={#MyAppName} {#MyAppVersion} +AppPublisher={#MyAppPublisher} +AppPublisherURL={#MyAppURL} +AppSupportURL={#MyAppIssuesURL} +AppUpdatesURL={#MyAppUpdateURL} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppShortName} +AllowNoIcons=yes +; Privilege level settings - set to lowest for per-user installation +; and allow the user to choose to run as admin if needed +PrivilegesRequired=lowest +; 64-bit only application +ArchitecturesAllowed=x64compatible +ArchitecturesInstallIn64BitMode=x64compatible +; Output settings +OutputDir=dist\installer +OutputBaseFilename=MPSS_Setup_{#MyAppVersion} +SetupIconFile=simkl_mps\assets\simkl-mps.ico +; Compression settings +Compression=lzma2/ultra64 +SolidCompression=yes +; Uninstall settings +UninstallDisplayIcon={app}\{#MyAppExeName}.exe +UninstallDisplayName={#MyAppShortName} +; Modern UI settings +WizardStyle=modern +WizardResizable=yes +WizardSizePercent=120 +; This adds Windows 10/11 compatibility settings +MinVersion=10.0.17763 +; App metadata +AppCopyright={#MyAppCopyright} +VersionInfoVersion={#MyAppVersion} +VersionInfoDescription={#MyAppDescription} +VersionInfoCopyright={#MyAppCopyright} +VersionInfoCompany={#MyAppPublisher} +VersionInfoProductName={#MyAppName} +VersionInfoProductVersion={#MyAppVersion} +; Support info +AppContact={#MyAppIssuesURL} +; License and readme files +LicenseFile=LICENSE +SetupMutex={#MyAppName}Setup +AlwaysRestart=no +RestartIfNeededByRun=yes +DisableDirPage=auto +DisableProgramGroupPage=auto +UsedUserAreasWarning=no + +[Languages] +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Messages] +WelcomeLabel1=Welcome to the [name] Setup Wizard +WelcomeLabel2=This will install [name/ver] on your computer.%n%nMedia Player Scrobbler for SIMKL automatically tracks what you watch in your media players and updates your SIMKL.com account.%n%nIt is recommended that you close all other applications before continuing. +FinishedHeadingLabel=Completing the [name] Setup Wizard +FinishedLabel=Setup has finished installing [name] on your computer. The application may be launched by selecting the installed shortcuts. +AboutSetupMenuItem=About [name]... +AboutSetupTitle=About [name] +AboutSetupMessage=[name] version [ver]%n[name] Media Player Scrobbler for SIMKL%n%nLicense: {#MyLicense}%n%nCopyright Ā© kavinthangavel%n{#MyAppURL} + +[CustomMessages] +LaunchAppDesc=Start MPS for SIMKL after installation +DesktopIconDesc=Create a desktop shortcut +StartupIconDesc=Start automatically when Windows starts +UpdateDesc=Schedule weekly update checks (recommended) +AboutApp=About Media Player Scrobbler for SIMKL +VersionInfo=Version: {#MyAppVersion} +LicenseInfo=License: {#MyLicense} + +[Tasks] +Name: "desktopicon"; Description: "{cm:DesktopIconDesc}"; GroupDescription: "Shortcuts:" +Name: "startupicon"; Description: "{cm:StartupIconDesc}"; GroupDescription: "Startup options:" +Name: "scheduledupdate"; Description: "{cm:UpdateDesc}"; GroupDescription: "Update options:" + +[Files] +; Main executable +Source: "dist\MPSS.exe"; DestDir: "{app}"; Flags: ignoreversion signonce +; Tray executable +Source: "dist\MPS for Simkl.exe"; DestDir: "{app}"; Flags: ignoreversion signonce +; All other files (DLLs, data, etc.) +Source: "dist\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs +; Include updater script directly +Source: "simkl_mps\utils\updater.ps1"; DestDir: "{app}"; Flags: ignoreversion +; Create version file to help with About dialog +Source: "LICENSE"; DestDir: "{app}"; Flags: ignoreversion + +[Icons] +; Start Menu entries - simplified to just have "Start Scrobbler" +Name: "{group}\{#MyAppShortName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "start"; Comment: "Start SIMKL scrobbler in the background" +Name: "{group}\{cm:UninstallProgram,{#MyAppShortName}}"; Filename: "{uninstallexe}" + +; Desktop icon - simplified to just one "Start Scrobbler" icon +Name: "{commondesktop}\{#MyAppShortName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "start"; Tasks: desktopicon; Check: IsAdminInstallMode; Comment: "Start SIMKL scrobbler in the background" +Name: "{userdesktop}\{#MyAppShortName}"; Filename: "{app}\{#MyAppExeName}"; Parameters: "start"; Tasks: desktopicon; Check: not IsAdminInstallMode; Comment: "Start SIMKL scrobbler in the background" + +; Startup entry - renamed to "MPS for Simkl.exe" with specific icon +Name: "{userstartup}\{#MyAppShortName}"; Filename: "{app}\{#MyAppTrayName}"; Parameters: "start"; IconFilename: "{app}\{#MyAppTrayName}"; Tasks: startupicon; Check: not IsAdminInstallMode; Comment: "Start SIMKL scrobbler in the background" +Name: "{commonstartup}\{#MyAppShortName}"; Filename: "{app}\{#MyAppTrayName}"; Parameters: "start"; IconFilename: "{app}\{#MyAppTrayName}"; Tasks: startupicon; Check: IsAdminInstallMode; Comment: "Start SIMKL scrobbler in the background" + +[Run] +; Options to run after installation +Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchAppDesc}"; Parameters: "start"; Flags: nowait postinstall skipifsilent runascurrentuser +Filename: "{#MyAppURL}"; Description: "Visit website"; Flags: postinstall shellexec skipifsilent unchecked + +[UninstallRun] +; Improved process termination for uninstallation +; First try to gracefully close the app +Filename: "{app}\{#MyAppExeName}.exe"; Parameters: "exit"; Flags: runhidden skipifdoesntexist; RunOnceId: "GracefulExit" +; Wait a moment before forcefully terminating +Filename: "{sys}\cmd.exe"; Parameters: "/c timeout /t 2 /nobreak > nul"; Flags: runhidden; RunOnceId: "WaitForExit" +; Forcefully terminate any remaining processes +Filename: "taskkill.exe"; Parameters: "/F /IM ""{#MyAppExeName}.exe"" /T"; Flags: runhidden skipifdoesntexist; RunOnceId: "KillMain" +Filename: "taskkill.exe"; Parameters: "/F /IM ""MPS for Simkl.exe"" /T"; Flags: runhidden skipifdoesntexist; RunOnceId: "KillTray" +; Add a Windows PowerShell command to find and kill all related processes - fix curly braces escaping +Filename: "powershell.exe"; Parameters: "-NoProfile -ExecutionPolicy Bypass -Command ""Get-Process | Where-Object {{$_.Path -like '*{{app}}*'}} | Stop-Process -Force"""; Flags: runhidden skipifdoesntexist runascurrentuser; RunOnceId: "KillAllRelated" +; Final wait to ensure processes have terminated +Filename: "{sys}\cmd.exe"; Parameters: "/c timeout /t 1 /nobreak > nul"; Flags: runhidden; RunOnceId: "FinalWait" + +[Registry] +; Create a version information file for the about dialog +Root: HKCU; Subkey: "Software\{#MyAppPublisher}\{#MyAppName}"; ValueType: string; ValueName: "Version"; ValueData: "{#MyAppVersion}"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\{#MyAppPublisher}\{#MyAppName}"; ValueType: string; ValueName: "License"; ValueData: "{#MyLicense}"; Flags: uninsdeletekey + +; Custom app registration (for uninstall) +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayIcon"; ValueData: "{app}\{#MyAppExeName}"; Flags: uninsdeletekey; Check: IsAdminInstallMode +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayName"; ValueData: "{#MyAppName}"; Flags: uninsdeletekey; Check: IsAdminInstallMode +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayVersion"; ValueData: "{#MyAppVersion}"; Flags: uninsdeletekey; Check: IsAdminInstallMode +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "Publisher"; ValueData: "{#MyAppPublisher}"; Flags: uninsdeletekey; Check: IsAdminInstallMode +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "URLInfoAbout"; ValueData: "{#MyAppURL}"; Flags: uninsdeletekey; Check: IsAdminInstallMode + +; Custom app registration for uninstall with explicit icon path +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "UninstallString"; ValueData: """{uninstallexe}"""; Flags: uninsdeletekey; Check: IsAdminInstallMode +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayIcon"; ValueData: "{app}\{#MyAppExeName}.exe"; Flags: uninsdeletekey; Check: IsAdminInstallMode +Root: HKLM; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayName"; ValueData: "{#MyAppShortName}"; Flags: uninsdeletekey; Check: IsAdminInstallMode + +; User installation registry entries (non-admin installations) +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayIcon"; ValueData: "{app}\{#MyAppExeName}"; Flags: uninsdeletekey; Check: not IsAdminInstallMode +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayName"; ValueData: "{#MyAppName}"; Flags: uninsdeletekey; Check: not IsAdminInstallMode +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayVersion"; ValueData: "{#MyAppVersion}"; Flags: uninsdeletekey; Check: not IsAdminInstallMode +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "Publisher"; ValueData: "{#MyAppPublisher}"; Flags: uninsdeletekey; Check: not IsAdminInstallMode +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "URLInfoAbout"; ValueData: "{#MyAppURL}"; Flags: uninsdeletekey; Check: not IsAdminInstallMode + +; User installation registry entries with updated uninstaller name and icon +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "UninstallString"; ValueData: """{uninstallexe}"""; Flags: uninsdeletekey; Check: not IsAdminInstallMode +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayIcon"; ValueData: "{app}\{#MyAppExeName}.exe"; Flags: uninsdeletekey; Check: not IsAdminInstallMode +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "DisplayName"; ValueData: "{#MyAppShortName}"; Flags: uninsdeletekey; Check: not IsAdminInstallMode + +; Auto-update settings +Root: HKCU; Subkey: "Software\{#MyAppPublisher}\{#MyAppName}"; ValueType: string; ValueName: "InstallPath"; ValueData: "{app}"; Flags: uninsdeletekey +Root: HKLM; Subkey: "Software\{#MyAppPublisher}\{#MyAppName}"; ValueType: string; ValueName: "InstallPath"; ValueData: "{app}"; Flags: uninsdeletekey; Check: IsAdminInstallMode + +; Add to Apps & Features list for non-admin installs (Windows 10+) +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: string; ValueName: "InstallLocation"; ValueData: "{app}"; Flags: uninsdeletekey; Check: not IsAdminInstallMode +Root: HKCU; Subkey: "Software\Microsoft\Windows\CurrentVersion\Uninstall\{{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1"; ValueType: dword; ValueName: "EstimatedSize"; ValueData: "50000"; Flags: uninsdeletekey; Check: not IsAdminInstallMode + +[Code] +const + CONFIG_FOLDER = 'simkl-mps'; + TASK_NAME = 'kavinthangavel.MediaPlayerScrobblerForSIMKL.UpdateCheck'; + +// Create the scheduled task for updates (optional, only if user selects the task) +function CreateUpdateScheduledTask: Boolean; +var + TaskName, AppPath, PowerShellPath, Params: String; + ResultCode: Integer; +begin + Result := False; + TaskName := TASK_NAME; + AppPath := ExpandConstant('{app}\updater.ps1'); + PowerShellPath := ExpandConstant('{sys}\WindowsPowerShell\v1.0\powershell.exe'); + Params := '-ExecutionPolicy Bypass -NoProfile -WindowStyle Hidden -File "' + AppPath + '" -Silent'; + try + Exec('schtasks.exe', '/Delete /TN "' + TaskName + '" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + if Exec('schtasks.exe', + '/Create /TN "' + TaskName + '" /TR "\"' + PowerShellPath + '\" ' + Params + '" /SC WEEKLY /D SAT /ST 12:00 /F', + '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Result := True; + except + // ignore errors + end; +end; + +// Remove the scheduled task +function RemoveUpdateScheduledTask: Boolean; +var + TaskName: String; + ResultCode: Integer; +begin + Result := False; + TaskName := TASK_NAME; + try + if Exec('schtasks.exe', '/Delete /TN "' + TaskName + '" /F', '', SW_HIDE, ewWaitUntilTerminated, ResultCode) then + Result := True; + except + // ignore errors + end; +end; + +// Create a version file with info for the app to use +procedure CreateVersionFile; +var + VersionFilePath: String; + VersionContents: String; +begin + VersionFilePath := ExpandConstant('{app}\version.txt'); + VersionContents := '{#MyAppVersion}'; + + if not SaveStringToFile(VersionFilePath, VersionContents, False) then + Log('Failed to create version.txt file'); +end; + +// Called after files are copied +procedure CurStepChanged(CurStep: TSetupStep); +begin + if CurStep = ssPostInstall then + begin + // Create version file + CreateVersionFile(); + + // Create scheduled task if selected + if WizardIsTaskSelected('scheduledupdate') then + CreateUpdateScheduledTask(); + end; +end; + +// Enhanced cleanup during uninstallation +procedure CurUninstallStepChanged(CurUninstallStep: TUninstallStep); +var + ConfigDirs: array of String; + i: Integer; + CleanupAll: Boolean; + UserProfileDir, AppDataDir, LocalAppDataDir: String; +begin + if CurUninstallStep = usPostUninstall then + begin + // Remove scheduled task + RemoveUpdateScheduledTask(); + + // Ask user about data removal + CleanupAll := SuppressibleMsgBox('Do you want to remove all user settings, logs, and application data?', + mbConfirmation, MB_YESNO, IDNO) = IDYES; + + if CleanupAll then + begin + // Get reliable paths for Windows environment folders + UserProfileDir := GetEnv('USERPROFILE'); + AppDataDir := GetEnv('APPDATA'); + LocalAppDataDir := GetEnv('LOCALAPPDATA'); + + // All possible config directories to check and remove - Windows specific + SetArrayLength(ConfigDirs, 6); + + // Primary location: C:\Users\username\kavinthangavel\simkl-mps + ConfigDirs[0] := UserProfileDir + '\kavinthangavel\' + CONFIG_FOLDER; + + // Other possible locations - using environment variables instead of constants + ConfigDirs[1] := LocalAppDataDir + '\' + CONFIG_FOLDER; + ConfigDirs[2] := AppDataDir + '\' + CONFIG_FOLDER; + ConfigDirs[3] := UserProfileDir + '\' + CONFIG_FOLDER; + ConfigDirs[4] := UserProfileDir + '\AppData\Local\' + CONFIG_FOLDER; + ConfigDirs[5] := UserProfileDir + '\Documents\' + CONFIG_FOLDER; + + // Log what directories we're checking + Log('Looking for configuration directories to clean up...'); + + // Loop through all possible locations and delete them if they exist + for i := 0 to GetArrayLength(ConfigDirs) - 1 do + begin + if DirExists(ConfigDirs[i]) then + begin + Log('Deleting configuration directory: ' + ConfigDirs[i]); + if not DelTree(ConfigDirs[i], True, True, True) then + begin + Log('Failed to delete directory with DelTree: ' + ConfigDirs[i]); + // Try CMD as fallback for difficult directories + Exec('cmd.exe', '/c rd /s /q "' + ConfigDirs[i] + '"', '', SW_HIDE, ewWaitUntilTerminated, i); + end; + end else + Log('Directory not found: ' + ConfigDirs[i]); + end; + + // Also clean registry entries + RegDeleteKeyIncludingSubkeys(HKCU, 'Software\{#MyAppPublisher}\{#MyAppName}'); + if IsAdminInstallMode then + RegDeleteKeyIncludingSubkeys(HKLM, 'Software\{#MyAppPublisher}\{#MyAppName}'); + end; + end; +end; + +// Enhanced uninstall preparation +function InitializeUninstall(): Boolean; +var + ResultCode: Integer; + ProcessName: string; +begin + // Ensure all application processes are terminated before continuing + ProcessName := ExtractFileName(ExpandConstant('{app}\{#MyAppExeName}.exe')); + + // Try graceful exit first + Exec(ExpandConstant('{app}\{#MyAppExeName}.exe'), 'exit', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + + // Wait briefly + Sleep(1000); + + // Forceful termination of any remaining processes + Exec('taskkill.exe', '/F /IM "' + ProcessName + '" /T', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(500); + + // Also try to terminate the tray application + Exec('taskkill.exe', '/F /IM "MPS for Simkl.exe" /T', '', SW_HIDE, ewWaitUntilTerminated, ResultCode); + Sleep(500); + + // Success - let uninstallation proceed + Result := True; +end; \ No newline at end of file diff --git a/simkl-mps.spec b/simkl-mps.spec new file mode 100644 index 0000000..2be7a24 --- /dev/null +++ b/simkl-mps.spec @@ -0,0 +1,330 @@ +import sys +from pathlib import Path +import guessit +import babelfish # Added import +import os +import time +import subprocess +import shutil + +# Get the directory containing this spec file +spec_dir = Path(SPECPATH) + +assets_path = spec_dir / 'simkl_mps' / 'assets' + +assets_dest = 'simkl_mps/assets' + +# Add pre-build cleanup to handle locked files +def cleanup_build_artifacts(): + """Attempt to clean up previous build artifacts that might be locked""" + print("Performing pre-build cleanup...") + dist_dir = os.path.join(spec_dir, 'dist') + + try: + # Try to terminate any running instances that might lock files + if sys.platform == 'win32': + # Windows-specific process termination + try: + subprocess.run(['taskkill', '/F', '/IM', 'MPS for Simkl.exe'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + subprocess.run(['taskkill', '/F', '/IM', 'MPSS.exe'], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + # Give Windows time to release file handles + time.sleep(1) + except Exception as e: + print(f"Warning: Could not terminate processes: {e}") + + # Check for locked executables and handle them + problematic_files = [ + os.path.join(dist_dir, 'MPS for Simkl.exe'), + os.path.join(dist_dir, 'MPSS.exe') + ] + + for file_path in problematic_files: + if os.path.exists(file_path): + try: + # First try: Normal deletion + os.remove(file_path) + print(f"Successfully removed {file_path}") + except PermissionError: + # Second try: Rename then delete + try: + temp_name = file_path + ".old" + os.rename(file_path, temp_name) + os.remove(temp_name) + print(f"Renamed and removed {file_path}") + except Exception as e: + print(f"Warning: Could not remove {file_path}: {e}") + # Final attempt - create new build directory + if os.path.dirname(file_path) == dist_dir: + new_dist = dist_dir + "_new" + if os.path.exists(new_dist): + shutil.rmtree(new_dist, ignore_errors=True) + os.makedirs(new_dist, exist_ok=True) + os.environ['DISTPATH'] = new_dist + print(f"Using alternative dist directory: {new_dist}") + except Exception as e: + print(f"Warning: Could not remove {file_path}: {e}") + except Exception as e: + print(f"Pre-build cleanup warning: {e}") + +# Run cleanup before build starts +cleanup_build_artifacts() + +# --- Find guessit data path --- +try: + guessit_base_path = os.path.dirname(guessit.__file__) + guessit_data_path = os.path.join(guessit_base_path, 'data') # Common location for data + if not os.path.isdir(guessit_data_path): + # Fallback or alternative check if needed + guessit_data_path = os.path.join(guessit.__path__[0], 'data') + print(f"Found guessit data path: {guessit_data_path}") # For verification during build +except Exception as e: + print(f"Warning: Could not automatically find guessit data path: {e}") + guessit_data_path = None # Handle error case if needed +# --- End find guessit data path --- + +# --- Find guessit config path --- +try: + # guessit_base_path is already defined above + guessit_config_path = os.path.join(guessit_base_path, 'config') + if not os.path.isdir(guessit_config_path): + guessit_config_path = os.path.join(guessit.__path__[0], 'config') + print(f"Found guessit config path: {guessit_config_path}") # For verification +except Exception as e: + print(f"Warning: Could not automatically find guessit config path: {e}") + guessit_config_path = None +# --- End find guessit config path --- + +# --- Find babelfish data path --- +try: + babelfish_base_path = os.path.dirname(babelfish.__file__) + babelfish_data_path = os.path.join(babelfish_base_path, 'data') + if not os.path.isdir(babelfish_data_path): + babelfish_data_path = os.path.join(babelfish.__path__[0], 'data') + print(f"Found babelfish data path: {babelfish_data_path}") # For verification +except Exception as e: + print(f"Warning: Could not automatically find babelfish data path: {e}") + babelfish_data_path = None +# --- End find babelfish data path --- + +block_cipher = None + +# Define platform-specific hidden imports +hidden_imports = [ + 'babelfish.language', + 'babelfish.converters', + 'babelfish.converters.alpha2', + 'babelfish.converters.alpha3b', + 'babelfish.converters.alpha3t', + 'babelfish.converters.countryname', + 'babelfish.converters.name', + 'babelfish.country', + 'babelfish.script', + 'babelfish.converters.opensubtitles', + 'babelfish.converters.scope', + 'babelfish.converters.terminator', +] + +# Add platform-specific imports +if sys.platform == 'win32': + hidden_imports.extend([ + 'plyer.platforms.win.notification', + 'win32api', 'win32con', 'win32gui', + 'tkinter', 'tkinter.ttk', # Add tkinter for dialogs + 'threading', # Ensure threading is included + ]) +elif sys.platform == 'darwin': + hidden_imports.extend([ + 'plyer.platforms.macosx.notification', + 'pystray._darwin', + 'Foundation', 'AppKit', 'Cocoa', + 'PyObjC', + 'tkinter', 'tkinter.ttk', # Add tkinter for dialogs + 'threading', # Ensure threading is included + ]) +elif sys.platform.startswith('linux'): + hidden_imports.extend([ + 'plyer.platforms.linux.notification', + 'pystray._xorg', + 'PIL._tkinter_finder', + 'gi', 'gi.repository.Gtk', 'gi.repository.GdkPixbuf', + 'gi.repository.GLib', 'gi.repository.Gio', + 'tkinter', 'tkinter.ttk', # Add tkinter for dialogs + 'threading', # Ensure threading is included + ]) + +# Main application analysis +a = Analysis( + ['simkl_mps/cli.py'], + pathex=[], + binaries=[], + datas=[ + (str(assets_path), assets_dest), # Your existing assets line + # Add the following line: + (guessit_data_path, 'guessit/data') if guessit_data_path and os.path.isdir(guessit_data_path) else None, + # Add babelfish data + (babelfish_data_path, 'babelfish/data') if babelfish_data_path and os.path.isdir(babelfish_data_path) else None, + # Add guessit config + (guessit_config_path, 'guessit/config') if guessit_config_path and os.path.isdir(guessit_config_path) else None + ], + hiddenimports=hidden_imports, + excludes=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + private_asss=False, + cipher=block_cipher, + noarchive=False, +) +# Filter None entries from datas, if any +a.datas = [d for d in a.datas if d is not None] + +# Include updater scripts in the distribution +a.datas += [ + ('utils/updater.ps1', 'simkl_mps/utils/updater.ps1', 'DATA'), + ('utils/updater.sh', 'simkl_mps/utils/updater.sh', 'DATA'), +] + +pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) + +# Determine icon path based on platform +if sys.platform == 'win32': + icon_path = str(assets_path / 'simkl-mps.ico') +elif sys.platform == 'darwin': + icon_path = str(assets_path / 'simkl-mps.icns') # Make sure this file exists in assets +else: # Linux and others + icon_path = str(assets_path / 'simkl-mps.png') + +exe = EXE( + pyz, + a.scripts, + a.binaries, + a.zipfiles, + a.datas, + [], + name='MPSS', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=True, # Set to False for a GUI-only app (no console window) + disable_windowed_traceback=False, + argv_emulation=False if sys.platform != 'darwin' else True, # Enable argv emulation for macOS + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=icon_path if os.path.exists(icon_path) else None, +) + +# Tray application analysis +tray_a = Analysis( + ['simkl_mps/tray_app.py'], + pathex=[], + binaries=[], + datas=[ + (str(assets_path), assets_dest), + (guessit_data_path, 'guessit/data') if guessit_data_path and os.path.isdir(guessit_data_path) else None, + (babelfish_data_path, 'babelfish/data') if babelfish_data_path and os.path.isdir(babelfish_data_path) else None, + (guessit_config_path, 'guessit/config') if guessit_config_path and os.path.isdir(guessit_config_path) else None + ], + hiddenimports=hidden_imports, + excludes=[], + hookspath=[], + hooksconfig={}, + runtime_hooks=[], + private_asss=False, + cipher=block_cipher, + noarchive=False, +) +# Filter None entries from datas, if any +tray_a.datas = [d for d in tray_a.datas if d is not None] + +# Include updater scripts in the distribution +tray_a.datas += [ + ('utils/updater.ps1', 'simkl_mps/utils/updater.ps1', 'DATA'), + ('utils/updater.sh', 'simkl_mps/utils/updater.sh', 'DATA'), +] + +tray_pyz = PYZ(tray_a.pure, tray_a.zipped_data, cipher=block_cipher) + +tray_exe = EXE( + tray_pyz, + tray_a.scripts, + tray_a.binaries, + tray_a.zipfiles, + tray_a.datas, + [], + name='MPS for Simkl', + debug=False, + bootloader_ignore_signals=False, + strip=False, + upx=True, + upx_exclude=[], + runtime_tmpdir=None, + console=False, # No console for tray app + disable_windowed_traceback=False, + argv_emulation=False if sys.platform != 'darwin' else True, # Enable argv emulation for macOS + target_arch=None, + codesign_identity=None, + entitlements_file=None, + icon=icon_path if os.path.exists(icon_path) else None, +) + +# For macOS, create application bundles +if sys.platform == 'darwin': + app = BUNDLE( + exe, + name='MPSS.app', + icon=str(assets_path / 'simkl-mps.icns'), # macOS icon format + bundle_identifier='com.simkl.mpss', + info_plist={ + 'NSHighResolutionCapable': 'True', + 'LSBackgroundOnly': 'False', + 'CFBundleDisplayName': 'Media Player Scrobbler for SIMKL', + 'CFBundleShortVersionString': '${VERSION}', + 'NSRequiresAquaSystemAppearance': 'False', + 'LSUIElement': '0', # Not a background-only app + }, + ) + + tray_app = BUNDLE( + tray_exe, + name='MPS for Simkl.app', + icon=str(assets_path / 'simkl-mps.icns'), + bundle_identifier='com.simkl.mpss.tray', + info_plist={ + 'NSHighResolutionCapable': 'True', + 'LSBackgroundOnly': 'True', # Background app for tray + 'CFBundleDisplayName': 'MPSS Tray', + 'CFBundleShortVersionString': '${VERSION}', + 'NSRequiresAquaSystemAppearance': 'False', + 'LSUIElement': '1', # Background-only app + }, + ) + +# For Linux, create a collect directory +elif sys.platform.startswith('linux'): + coll = COLLECT( + exe, + a.binaries, + a.zipfiles, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='simkl-mps', + ) + + tray_coll = COLLECT( + tray_exe, + tray_a.binaries, + tray_a.zipfiles, + tray_a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='simkl-mps-tray', + ) \ No newline at end of file diff --git a/simkl_mps/__init__.py b/simkl_mps/__init__.py index d884a83..09fd59f 100644 --- a/simkl_mps/__init__.py +++ b/simkl_mps/__init__.py @@ -5,11 +5,11 @@ __version__ = "1.0.0" __author__ = "kavinthangavel" -from .compatibility_patches import apply_patches +from simkl_mps.compatibility_patches import apply_patches apply_patches() -from .main import SimklScrobbler, run_as_background_service, main -from .tray_app import run_tray_app +from simkl_mps.main import SimklScrobbler, run_as_background_service, main +from simkl_mps.tray_app import run_tray_app __all__ = [ 'SimklScrobbler', 'run_as_background_service', diff --git a/simkl_mps/cli.py b/simkl_mps/cli.py index 03fff78..dcb2c8b 100644 --- a/simkl_mps/cli.py +++ b/simkl_mps/cli.py @@ -198,35 +198,47 @@ def start_command(args): from simkl_mps.tray_app import run_tray_app sys.exit(run_tray_app()) - print("[*] Launching application with tray icon in background...") logger.info("Launching tray application in detached process.") + try: - + # Determine the command to launch the tray application if getattr(sys, 'frozen', False): - + # We're running in a PyInstaller bundle exe_dir = Path(sys.executable).parent - - tray_exe = exe_dir / "simkl-mps-tray.exe" - if tray_exe.exists(): - cmd = [str(tray_exe)] - logger.debug(f"Launching dedicated tray executable: {tray_exe}") + + # Look for the dedicated tray executable - now named "MPS for Simkl.exe" + tray_exe_paths = [ + exe_dir / "MPS for Simkl.exe", # Windows - new name + exe_dir / "MPS for Simkl", # Linux/macOS - new name + ] + + # Use the first tray executable that exists + for tray_path in tray_exe_paths: + if tray_path.exists(): + cmd = [str(tray_path)] + logger.info(f"Using dedicated tray executable: {tray_path}") + break else: - + # No dedicated tray executable found - use the main executable with the tray parameter cmd = [sys.executable, "tray"] - logger.debug("Launching frozen executable for tray (fallback method).") + logger.info("Using main executable with 'tray' parameter as fallback") else: + # Not frozen - launch as a Python module cmd = [sys.executable, "-m", "simkl_mps.tray_app"] - logger.debug("Launching tray via python module.") + logger.info("Launching tray via Python module (development mode)") + # Set up environment for subprocess + env = os.environ.copy() + env["SIMKL_TRAY_SUBPROCESS"] = "1" # Mark as subprocess + if sys.platform == "win32": + # Windows-specific process creation CREATE_NO_WINDOW = 0x08000000 DETACHED_PROCESS = 0x00000008 + startupinfo = subprocess.STARTUPINFO() startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW - - env = os.environ.copy() - env["SIMKL_TRAY_SUBPROCESS"] = "1" # Mark this as a subprocess subprocess.Popen( cmd, @@ -234,14 +246,11 @@ def start_command(args): close_fds=True, shell=False, startupinfo=startupinfo, - env=env # Pass the environment with our marker + env=env ) - logger.info("Launched detached process on Windows.") - else: # Assume Unix-like - - env = os.environ.copy() - env["SIMKL_TRAY_SUBPROCESS"] = "1" # Mark this as a subprocess - + logger.info("Launched detached process on Windows") + else: + # Unix-like systems (Linux, macOS) subprocess.Popen( cmd, start_new_session=True, @@ -249,12 +258,12 @@ def start_command(args): stderr=subprocess.DEVNULL, close_fds=True, shell=False, - env=env # Pass the environment with our marker + env=env ) - logger.info("Launched detached process on Unix-like system.") + logger.info("Launched detached process on Unix-like system") print(f"{Fore.GREEN}[āœ“] Scrobbler launched successfully in background.{Style.RESET_ALL}") - print(f"[*] Look for the simkl-mps icon in your system tray.") + print(f"[*] Look for the SIMKL-MPS icon in your system tray.") print(f"{Fore.GREEN}[āœ“] You can safely close this terminal window. All processes will continue running.{Style.RESET_ALL}") return 0 except Exception as e: @@ -313,6 +322,56 @@ def version_command(args): print(f"\nData directory: {APP_DATA_DIR}") return 0 +def check_for_updates(silent=False): + """ + Check for updates to the application. + + Args: + silent (bool): If True, run silently with no user interaction + + Returns: + bool: True if update check was successful, False otherwise + """ + logger.info("Checking for updates...") + + try: + import subprocess + import os + from pathlib import Path + + # Get the path to the updater script + if getattr(sys, 'frozen', False): + # Running as frozen executable + updater_path = Path(sys.executable).parent / "updater.ps1" + else: + # Running in development mode + updater_path = Path(__file__).parent / "utils" / "updater.ps1" + + if not updater_path.exists(): + logger.error(f"Updater script not found at {updater_path}") + return False + + # Build the PowerShell command + args = [ + "powershell.exe", + "-ExecutionPolicy", "Bypass", + "-File", str(updater_path) + ] + + if silent: + args.append("-Silent") + + args.append("-CheckOnly") # Just check, don't install automatically + + # Run the updater + logger.debug(f"Running updater: {' '.join(args)}") + subprocess.Popen(args) + return True + + except Exception as e: + logger.error(f"Error checking for updates: {e}") + return False + def create_parser(): """ Creates and configures the argument parser for the CLI. @@ -375,6 +434,21 @@ def main(): if not hasattr(args, 'command') or not args.command: parser.print_help() return 0 + + # Check for updates when starting the app (except for the tray subprocess) + if os.environ.get("SIMKL_TRAY_SUBPROCESS") != "1" and args.command in ["start", "tray"]: + # Check if user has enabled update checks + import winreg + try: + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\kavinthangavel\Media Player Scrobbler for SIMKL") as key: + check_updates = winreg.QueryValueEx(key, "CheckUpdates")[0] + if check_updates == 1: + logger.info("Auto-update check enabled, checking for updates...") + check_for_updates(silent=True) + except (OSError, ImportError, Exception) as e: + # If registry key doesn't exist or other error, default to checking for updates + logger.debug(f"Error checking update preferences, defaulting to check: {e}") + check_for_updates(silent=True) command_map = { "init": init_command, diff --git a/simkl_mps/monitor.py b/simkl_mps/monitor.py index 7221503..817a652 100644 --- a/simkl_mps/monitor.py +++ b/simkl_mps/monitor.py @@ -14,7 +14,7 @@ get_all_windows_info, is_video_player ) -from .movie_scrobbler import MovieScrobbler +from simkl_mps.movie_scrobbler import MovieScrobbler logger = logging.getLogger(__name__) diff --git a/simkl_mps/movie_scrobbler.py b/simkl_mps/movie_scrobbler.py index b871879..b296b67 100644 --- a/simkl_mps/movie_scrobbler.py +++ b/simkl_mps/movie_scrobbler.py @@ -110,6 +110,12 @@ def _log_playback_event(self, event_type, extra_data=None): except Exception as e: logger.error(f"Failed to log playback event: {e} - Data: {log_entry}") + if event_type == "scrobble_update" and self.notification_callback: + self.notification_callback( + "Scrobble Update", + f"Movie: '{self.movie_name or self.currently_tracking}' (Duration: {self.estimated_duration // 60 if self.estimated_duration else 'Unknown'})\nID: {self.simkl_id or 'N/A'}" + ) + def get_player_position_duration(self, process_name): """ Get current position and total duration from supported media players via web interfaces. @@ -372,9 +378,7 @@ def stop_tracking(self): final_pos = self.current_position_seconds final_watch_time = self.watch_time if self.is_complete(): - logger.info(f"Marking '{self.movie_name or self.currently_tracking}' as watched due to completion threshold.") - if self.simkl_id: - self.mark_as_watched(self.simkl_id, self.movie_name or self.currently_tracking) + logger.info(f"Tracking stopped for '{self.movie_name or self.currently_tracking}' after completion threshold was met.") final_scrobble_info = { "title": self.currently_tracking, diff --git a/simkl_mps/tray_app.py b/simkl_mps/tray_app.py index 5dc9340..a8b83e7 100644 --- a/simkl_mps/tray_app.py +++ b/simkl_mps/tray_app.py @@ -37,15 +37,34 @@ def __init__(self): self.config_path = APP_DATA_DIR / ".simkl_mps.env" self.log_path = APP_DATA_DIR / "simkl_mps.log" + # Improved asset path resolution for frozen applications if getattr(sys, 'frozen', False): - - base_path = Path(sys._MEIPASS) - else: - - base_path = Path(__file__).parent + # When frozen, look for assets in multiple locations + base_dir = Path(sys._MEIPASS) + possible_asset_paths = [ + base_dir / "simkl_mps" / "assets", # Standard location in the frozen app + base_dir / "assets", # Alternative location + Path(sys.executable).parent / "simkl_mps" / "assets", # Beside the executable + Path(sys.executable).parent / "assets" # Beside the executable (alternative) + ] - self.assets_dir = base_path / "assets" - logger.info(f"Assets directory set to: {self.assets_dir}") + # Find the first valid assets directory + for path in possible_asset_paths: + if path.exists() and path.is_dir(): + self.assets_dir = path + logger.info(f"Using assets directory from frozen app: {self.assets_dir}") + break + else: + # If no directory was found, use a fallback + self.assets_dir = base_dir + logger.warning(f"No assets directory found in frozen app. Using fallback: {self.assets_dir}") + else: + # When running normally, assets are relative to this script's dir + self.assets_dir = Path(__file__).parent / "assets" + logger.info(f"Using assets directory from source: {self.assets_dir}") + + # Check for first run auto-update setup + self._setup_auto_update_if_needed() self.setup_icon() @@ -78,29 +97,32 @@ def update_status(self, new_status, details="", last_scrobbled=None): def load_icon_for_status(self): """Load the appropriate icon for the current status""" try: + # Try multiple icon formats and fallbacks icon_format = "ico" if sys.platform == "win32" else "png" - icon_name = f"simkl-mps-{self.status}.{icon_format}" - icon_path = self.assets_dir / icon_name - if not icon_path.exists(): - icon_name = f"simkl-mps.{icon_format}" - icon_path = self.assets_dir / icon_name + # List of possible icon files to check in order of preference + icon_paths = [ + # Status-specific icons + self.assets_dir / f"simkl-mps-{self.status}.{icon_format}", + self.assets_dir / f"simkl-mps-{self.status}.png", # PNG fallback + self.assets_dir / f"simkl-mps-{self.status}.ico", # ICO fallback + + # Generic icons + self.assets_dir / f"simkl-mps.{icon_format}", + self.assets_dir / f"simkl-mps.png", # PNG fallback + self.assets_dir / f"simkl-mps.ico" # ICO fallback + ] - if not icon_path.exists(): - alt_format = "png" if sys.platform == "win32" else "ico" - icon_name = f"simkl-mps-{self.status}.{alt_format}" - icon_path = self.assets_dir / icon_name + # Use the first icon that exists + for icon_path in icon_paths: + if icon_path.exists(): + logger.debug(f"Loading tray icon: {icon_path}") + return Image.open(icon_path) - if not icon_path.exists(): - icon_name = f"simkl-mps.{alt_format}" - icon_path = self.assets_dir / icon_name + logger.error(f"No suitable icon found in assets directory: {self.assets_dir}") + logger.error(f"Expected one of: {[p.name for p in icon_paths]}") + return self._create_fallback_image() - if icon_path.exists(): - logger.debug(f"Loading tray icon: {icon_path}") - return Image.open(icon_path) - else: - logger.error(f"No suitable icon found in assets directory: {self.assets_dir}") - return self._create_fallback_image() except FileNotFoundError as e: logger.error(f"Icon file not found: {e}", exc_info=True) return self._create_fallback_image() @@ -169,16 +191,18 @@ def get_status_text(self): def create_menu(self): """Create the system tray menu with a professional layout""" menu_items = [ - pystray.MenuItem("šŸ“Œ MPS for SIMKL", None), + pystray.MenuItem("^_^ MPS for SIMKL", None), pystray.Menu.SEPARATOR, pystray.MenuItem(f"Status: {self.get_status_text()}", None, enabled=False), pystray.Menu.SEPARATOR, ] - if self.status == "running": + + if self.status == "running" or self.status == "active": menu_items.append(pystray.MenuItem("Pause", self.pause_monitoring)) else: menu_items.append(pystray.MenuItem("Start", self.start_monitoring)) - menu_items += [ + + menu_items.extend([ pystray.Menu.SEPARATOR, pystray.MenuItem("Tools", pystray.Menu( pystray.MenuItem("Open Logs", self.open_logs), @@ -188,13 +212,14 @@ def create_menu(self): pystray.MenuItem("Online Services", pystray.Menu( pystray.MenuItem("SIMKL Website", self.open_simkl), pystray.MenuItem("View Watch History", self.open_simkl_history), - pystray.MenuItem("Check for Updates", self.check_updates) )), pystray.Menu.SEPARATOR, + pystray.MenuItem("Check for Updates", self.check_updates), pystray.MenuItem("About", self.show_about), pystray.MenuItem("Help", self.show_help), pystray.MenuItem("Exit", self.exit_app) - ] + ]) + return pystray.Menu(*menu_items) def update_icon(self): @@ -235,6 +260,148 @@ def open_config_dir(self, _=None): except Exception as e: logger.error(f"Error opening config directory: {e}") + def show_about(self, _=None): + """Show application information""" + try: + # Try multiple ways to get the version information + version = "Unknown" + + # 1. Try to get from pkg_resources + try: + import pkg_resources + version = pkg_resources.get_distribution("simkl-mps").version + except (pkg_resources.DistributionNotFound, ImportError): + # 2. Check for version.txt file + version_file = Path(self._get_app_path()) / "version.txt" + if version_file.exists(): + try: + version = version_file.read_text().strip() + except: + pass + + # 3. Try to get from registry (Windows) + if version == "Unknown" and sys.platform == 'win32': + try: + import winreg + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\kavinthangavel\Media Player Scrobbler for SIMKL") + version = winreg.QueryValueEx(key, "Version")[0] + winreg.CloseKey(key) + except: + pass + + # Get license information + license_name = "GNU GPL v3" + try: + if sys.platform == 'win32': + import winreg + key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\kavinthangavel\Media Player Scrobbler for SIMKL") + license_name = winreg.QueryValueEx(key, "License")[0] + winreg.CloseKey(key) + except: + pass + + # Build the about text with the version and license + about_text = f"""Media Player Scrobbler for SIMKL +Version: {version} +Author: kavinthangavel +License: {license_name} + +Automatically track and scrobble your media to SIMKL.""" + + # Display about dialog using appropriate method for platform + if sys.platform == 'win32': + # Use tkinter on Windows with proper event handling + import tkinter as tk + from tkinter import messagebox + + def show_dialog(): + dialog_root = tk.Tk() + dialog_root.withdraw() + dialog_root.attributes("-topmost", True) # Keep dialog on top + + # Add protocol handler for window close button + dialog_root.protocol("WM_DELETE_WINDOW", dialog_root.destroy) + + # Show the dialog and wait for it to complete + messagebox.showinfo("About", about_text, parent=dialog_root) + + # Clean up + dialog_root.destroy() + + # Run in a separate thread to avoid blocking the main thread + threading.Thread(target=show_dialog, daemon=True).start() + + elif sys.platform == 'darwin': + # Use AppleScript dialog on macOS + os.system(f'osascript -e \'display dialog "{about_text}" buttons {{"OK"}} default button "OK" with title "About MPS for SIMKL"\'') + else: + # On Linux, try using zenity or fall back to notification + import subprocess + try: + subprocess.run(['zenity', '--info', '--title=About MPS for SIMKL', f'--text={about_text}']) + except (FileNotFoundError, subprocess.SubprocessError): + self.show_notification("About MPS for SIMKL", about_text) + except Exception as e: + logger.error(f"Error showing about dialog: {e}") + self.show_notification("About", "Media Player Scrobbler for SIMKL") + + def _get_app_path(self): + """Get the application installation path""" + if getattr(sys, 'frozen', False): + return Path(sys.executable).parent + else: + import simkl_mps + return Path(simkl_mps.__file__).parent.parent + + def show_help(self, _=None): + """Show help information""" + try: + # Open documentation or show help dialog + help_url = "https://github.com/kavinthangavel/simkl-movie-tracker#readme" + webbrowser.open(help_url) + except Exception as e: + logger.error(f"Error showing help: {e}") + + # Fallback help text if browser doesn't open + help_text = """Media Player Scrobbler for SIMKL + +This application automatically tracks what you watch in supported media players and updates your SIMKL account. + +Supported players: +- VLC +- MPV +- MPC-HC +- PotPlayer + +Tips: +- Make sure you've authorized with SIMKL +- The app runs in your system tray +- Check logs if you encounter problems""" + + # Show help text in a dialog + if sys.platform == 'win32': + import tkinter as tk + from tkinter import messagebox + + def show_dialog(): + dialog_root = tk.Tk() + dialog_root.withdraw() + dialog_root.attributes("-topmost", True) + + # Add protocol handler for window close button + dialog_root.protocol("WM_DELETE_WINDOW", dialog_root.destroy) + + # Show the dialog and wait for it to complete + messagebox.showinfo("Help", help_text, parent=dialog_root) + + # Clean up + dialog_root.destroy() + + # Run in a separate thread to avoid blocking the main thread + threading.Thread(target=show_dialog, daemon=True).start() + else: + self.show_notification("Help", "Opening help documentation in browser") + def open_simkl(self, _=None): """Open the SIMKL website""" webbrowser.open("https://simkl.com") @@ -245,23 +412,218 @@ def open_simkl_history(self, _=None): def check_updates(self, _=None): """Check for updates to the application""" - webbrowser.open("https://github.com/kavinthangavel/simkl-movie-tracker/releases") + logger.info("Checking for updates...") + + import platform + import subprocess + import sys + from pathlib import Path + + system = platform.system().lower() + + try: + if system == 'windows': + # Windows update using PowerShell + from simkl_mps.cli import check_for_updates + check_for_updates(silent=False) + elif system == 'darwin': # macOS + # Use macOS update script + updater_path = self._get_updater_path('updater.sh') + if updater_path.exists(): + subprocess.Popen(['bash', str(updater_path)]) + else: + self.show_notification("Update Error", "Updater script not found for macOS") + elif system == 'linux': + # Use Linux update script + updater_path = self._get_updater_path('updater.sh') + if updater_path.exists(): + subprocess.Popen(['bash', str(updater_path)]) + else: + self.show_notification("Update Error", "Updater script not found for Linux") + else: + self.show_notification("Update Error", f"Updates not supported on {system}") + except Exception as e: + logger.error(f"Error checking for updates: {e}") + self.show_notification("Update Error", f"Failed to check for updates: {e}") - def show_help(self, _=None): - """Show help information""" - webbrowser.open("https://github.com/kavinthangavel/media-player-scrobbler-for-simkl/wiki") + def _get_updater_path(self, filename): + """Get the path to the updater script""" + import sys + from pathlib import Path + + # Check if we're running from an executable or source + if getattr(sys, 'frozen', False): + # Running from executable + app_path = Path(sys.executable).parent + return app_path / filename + else: + # Running from source + import simkl_mps + module_path = Path(simkl_mps.__file__).parent + return module_path / "utils" / filename - def show_about(self, _=None): - """Show information about the application""" - about_text = ( - "MPS for SIMKL\n" - "Version: 1.0.0\n" - "Author: kavinthangavel\n" - "\nMedia Player Scrobbler for SIMKL.\n" - "Tracks movies you watch and syncs with your Simkl account." - ) - self.show_notification("About", about_text) - logger.info("Displayed About notification from tray.") + def _get_icon_path(self, status="active"): + """Get the path to an icon file based on status, prioritizing high-resolution icons""" + try: + # Platform-specific considerations + if sys.platform == "win32": + # Windows prefers ICO files, but can use high-res PNGs too + preferred_formats = ["ico", "png"] + preferred_sizes = [256, 128, 64, 32] # Ordered by preference (highest first) + elif sys.platform == "darwin": + # macOS works best with high-res PNG files + preferred_formats = ["png", "ico"] + preferred_sizes = [512, 256, 128, 64] # macOS prefers higher res + else: + # Linux typically uses PNG files + preferred_formats = ["png", "ico"] + preferred_sizes = [256, 128, 64, 32] + + # First, try to find size-specific icons with the status + for size in preferred_sizes: + for fmt in preferred_formats: + # Check for size-specific status icon + paths = [ + self.assets_dir / f"simkl-mps-{status}-{size}.{fmt}", + self.assets_dir / f"simkl-mps-{size}.{fmt}" # Generic size-specific + ] + for path in paths: + if path.exists(): + logger.debug(f"Using high-resolution icon for notification: {path}") + return str(path) + + # If we don't find size-specific icons, try the standard ones + icon_paths = [] + + # Add status-specific icons first + for fmt in preferred_formats: + icon_paths.append(self.assets_dir / f"simkl-mps-{status}.{fmt}") + + # Add general icons as fallback + for fmt in preferred_formats: + icon_paths.append(self.assets_dir / f"simkl-mps.{fmt}") + + # Try to find any usable icon + for path in icon_paths: + if path.exists(): + logger.debug(f"Using standard icon for notification: {path}") + return str(path) + + # Last resort - look in the system path for the executable's directory + if getattr(sys, 'frozen', False): + exe_dir = Path(sys.executable).parent + for fmt in preferred_formats: + icon_path = exe_dir / f"simkl-mps.{fmt}" + if icon_path.exists(): + logger.debug(f"Using executable directory icon: {icon_path}") + return str(icon_path) + + # If no icon found, return None + logger.warning(f"No suitable icon found for notifications in: {self.assets_dir}") + return None + + except Exception as e: + logger.error(f"Error finding icon path: {e}") + return None + + def show_notification(self, title, message): + """Show a desktop notification with improved error handling and fallbacks""" + logger.debug(f"Attempting to show notification: {title} - {message}") + + # Skip directly to iconless notification since we're having icon loading issues + try: + # Try with plyer but explicitly without an icon + notification.notify( + title=title, + message=message, + app_name="MPS for SIMKL", + # No app_icon parameter to avoid the icon loading error + timeout=10 + ) + logger.debug("Icon-less notification sent successfully") + return + except Exception as plyer_err: + logger.warning(f"Basic notification failed: {plyer_err}") + + # Second try: Platform-specific native methods with no icons + try: + if sys.platform == 'win32': + # Windows: Try PowerShell with no icon references + try: + import subprocess + script = f''' + Add-Type -AssemblyName System.Windows.Forms + $notification = New-Object System.Windows.Forms.NotifyIcon + $notification.Text = "MPS for SIMKL" + $notification.Visible = $true + $notification.BalloonTipTitle = "{title}" + $notification.BalloonTipText = "{message}" + $notification.ShowBalloonTip(10000) + Start-Sleep -Seconds 5 + $notification.Dispose() + ''' + + with open("temp_notify.ps1", "w") as f: + f.write(script) + + subprocess.Popen( + ["powershell", "-ExecutionPolicy", "Bypass", "-File", "temp_notify.ps1"], + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + creationflags=subprocess.CREATE_NO_WINDOW + ) + logger.debug("Windows System.Windows.Forms notification sent") + return + except Exception as win_err: + logger.warning(f"Alternative Windows notification failed: {win_err}") + + # Windows MessageBox fallback + try: + import ctypes + MessageBox = ctypes.windll.user32.MessageBoxW + MB_ICONINFORMATION = 0x40 + MessageBox(None, message, title, MB_ICONINFORMATION) + logger.debug("Windows MessageBox notification shown") + return + except Exception as mb_err: + logger.warning(f"Windows MessageBox notification failed: {mb_err}") + + elif sys.platform == 'darwin': # macOS + try: + # For macOS, use a simpler AppleScript command with no icon reference + os_cmd = f'''osascript -e 'display notification "{message}" with title "{title}"' ''' + os.system(os_cmd) + logger.debug("Simple macOS notification sent") + return + except Exception as mac_err: + logger.warning(f"Simple macOS notification failed: {mac_err}") + + elif sys.platform.startswith('linux'): # Linux + try: + # Try notify-send without an icon + import subprocess + subprocess.run(['notify-send', title, message], check=False) + logger.debug("Linux notification sent via notify-send without icon") + return + except Exception as linux_err: + logger.warning(f"Basic Linux notification failed: {linux_err}") + + try: + # Try zenity without icon + import subprocess + subprocess.Popen(['zenity', '--notification', '--text', f"{title}: {message}"]) + logger.debug("Linux notification sent via zenity") + return + except Exception as zenity_err: + logger.warning(f"Zenity notification failed: {zenity_err}") + + except Exception as native_err: + logger.error(f"All native notification methods failed: {native_err}") + + # Final fallback: Print to console + print(f"\nšŸ”” NOTIFICATION: {title}\n{message}\n") + logger.info(f"Notification displayed in console: {title} - {message}") def run(self): """Run the tray application""" @@ -436,17 +798,80 @@ def exit_app(self, _=None): if self.tray_icon: self.tray_icon.stop() - def show_notification(self, title, message): - """Show a desktop notification""" + def _setup_auto_update_if_needed(self): + """Set up auto-updates if this is the first run""" try: - notification.notify( - title=title, - message=message, - app_name="MPS for SIMKL", - timeout=5 - ) + import platform + import subprocess + import os + from pathlib import Path + + config_dir = Path.home() / ".config" / "simkl-mps" + first_run_file = config_dir / "first_run" + + # Only run if the first_run file exists + if first_run_file.exists(): + system = platform.system().lower() + + if system == 'darwin': # macOS + # The LaunchAgent should already be set up by the installer + # Just run the updater with the first-run check flag + updater_path = self._get_updater_path('updater.sh') + if updater_path.exists(): + subprocess.Popen(['bash', str(updater_path), '--check-first-run']) + + elif system.startswith('linux'): + # For Linux, check if systemd is available and if the timer is set up + updater_path = self._get_updater_path('updater.sh') + setup_script_path = self._get_updater_path('setup-auto-update.sh') + + if updater_path.exists(): + # Run the updater with the first-run check flag + subprocess.Popen(['bash', str(updater_path), '--check-first-run']) + + # If setup script exists and systemd is available but timer not set up, + # ask the user if they want to enable auto-updates + if setup_script_path.exists(): + import tkinter as tk + from tkinter import messagebox + + systemd_user_dir = Path.home() / ".config" / "systemd" / "user" + timer_file = systemd_user_dir / "simkl-mps-updater.timer" + + if not timer_file.exists(): + def show_auto_update_dialog(): + dialog_root = tk.Tk() + dialog_root.withdraw() + dialog_root.attributes("-topmost", True) + + # Add protocol handler for window close button + dialog_root.protocol("WM_DELETE_WINDOW", lambda: dialog_root.destroy()) + + # Ask user about enabling auto-updates + result = messagebox.askyesno( + "MPSS Auto-Update", + "Would you like to enable weekly automatic update checks?", + parent=dialog_root + ) + + # Process the result before destroying the root + if result: + # Run the setup script + subprocess.run(['bash', str(setup_script_path)]) + + # Ensure dialog is destroyed + dialog_root.destroy() + + # Run dialog in a separate thread to avoid blocking + dialog_thread = threading.Thread(target=show_auto_update_dialog, daemon=True) + dialog_thread.start() + dialog_thread.join(timeout=10) # Wait for dialog with timeout + + # Remove the first_run file regardless of outcome + first_run_file.unlink(missing_ok=True) + except Exception as e: - logger.error(f"Failed to show notification: {e}") + logger.error(f"Error setting up auto-updates: {e}") def run_tray_app(): """Run the application in tray mode""" diff --git a/simkl_mps/utils/icon_generator.py b/simkl_mps/utils/icon_generator.py deleted file mode 100644 index 4c98571..0000000 --- a/simkl_mps/utils/icon_generator.py +++ /dev/null @@ -1,199 +0,0 @@ -""" -Icon generator utility for SIMKL Media Player Scrobbler. -Converts the PNG logo into appropriate formats for the tray icon. -""" - -import os -import sys -import logging -from pathlib import Path -from PIL import Image, ImageDraw - -logger = logging.getLogger(__name__) - -def generate_icons(base_size=128, output_dir=None, source_file=None): - """ - Generate various icon formats from the source PNG file. - - Args: - base_size: Base size for the icon (default: 128) - output_dir: Directory to save the generated icons - source_file: Source PNG file path - - Returns: - Dictionary of paths to the generated icons - """ - try: - script_dir = Path(__file__).parent.parent - if source_file is None: - source_file = script_dir / "assets" / "simkl-mps.png" - if output_dir is None: - output_dir = script_dir / "assets" - - os.makedirs(output_dir, exist_ok=True) - - if not os.path.exists(source_file): - logger.error(f"Source file not found: {source_file}") - return None - - img = Image.open(source_file) - img = img.convert("RGBA") - - icon_sizes = [16, 24, 32, 48, 64, 128, 256] - icons = {} - - for size in icon_sizes: - resized = img.resize((size, size), Image.LANCZOS) - output_path = output_dir / f"simkl-mps-{size}.png" - resized.save(output_path) - icons[f"{size}"] = output_path - - ico_path = output_dir / "simkl-mps.ico" - img.save(ico_path, format="ICO", sizes=[(s, s) for s in icon_sizes]) - icons["ico"] = ico_path - - status_colors = { - "running": (34, 177, 76, 255), # Green - "paused": (255, 127, 39, 255), # Orange - "error": (237, 28, 36, 255), # Red - "stopped": (112, 146, 190, 255) # Blue - } - - for status, color in status_colors.items(): - base_with_status = img.copy().resize((base_size, base_size), Image.LANCZOS) - draw = ImageDraw.Draw(base_with_status) - - indicator_size = base_size // 3 - ring_color = tuple(int(c * 0.8) for c in color[:3]) + (255,) - - if status == "paused": - padding = indicator_size // 4 - bar_width = (indicator_size - (padding * 3)) // 2 - - draw.ellipse( - [(base_size - indicator_size, base_size - indicator_size), - (base_size, base_size)], - fill=color, - outline=ring_color, - width=max(1, indicator_size // 10) - ) - - bar_color = (255, 255, 255, 220) - - draw.rectangle( - [(base_size - indicator_size + padding, - base_size - indicator_size + padding), - (base_size - indicator_size + padding + bar_width, - base_size - padding)], - fill=bar_color - ) - - draw.rectangle( - [(base_size - indicator_size + padding * 2 + bar_width, - base_size - indicator_size + padding), - (base_size - indicator_size + padding * 2 + bar_width * 2, - base_size - padding)], - fill=bar_color - ) - - elif status == "running": - draw.ellipse( - [(base_size - indicator_size, base_size - indicator_size), - (base_size, base_size)], - fill=color, - outline=ring_color, - width=max(1, indicator_size // 10) - ) - - triangle_color = (255, 255, 255, 220) - padding = indicator_size // 4 - - center_x = base_size - indicator_size // 2 - center_y = base_size - indicator_size // 2 - triangle_size = indicator_size // 2 - padding - - draw.polygon( - [(center_x - triangle_size // 2, center_y - triangle_size), - (center_x - triangle_size // 2, center_y + triangle_size), - (center_x + triangle_size, center_y)], - fill=triangle_color - ) - - elif status == "error": - draw.ellipse( - [(base_size - indicator_size, base_size - indicator_size), - (base_size, base_size)], - fill=color, - outline=ring_color, - width=max(1, indicator_size // 10) - ) - - x_color = (255, 255, 255, 220) - padding = indicator_size // 3 - line_width = max(2, indicator_size // 12) - - x1 = base_size - indicator_size + padding - y1 = base_size - indicator_size + padding - x2 = base_size - padding - y2 = base_size - padding - - draw.line([(x1, y1), (x2, y2)], fill=x_color, width=line_width) - draw.line([(x1, y2), (x2, y1)], fill=x_color, width=line_width) - - else: - draw.ellipse( - [(base_size - indicator_size, base_size - indicator_size), - (base_size, base_size)], - fill=color, - outline=ring_color, - width=max(1, indicator_size // 10) - ) - - if status == "stopped": - stop_color = (255, 255, 255, 220) - padding = indicator_size // 3 - - draw.rectangle( - [(base_size - indicator_size + padding, - base_size - indicator_size + padding), - (base_size - padding, base_size - padding)], - fill=stop_color - ) - - status_path = output_dir / f"simkl-mps-{status}.png" - base_with_status.save(status_path) - icons[status] = status_path - - ico_status_path = output_dir / f"simkl-mps-{status}.ico" - base_with_status.save(ico_status_path, format="ICO", sizes=[(s, s) for s in [16, 24, 32, 48, 64, 128]]) - icons[f"{status}_ico"] = ico_status_path - - logger.info(f"Generated {len(icons)} icon files in {output_dir}") - return icons - - except Exception as e: - logger.error(f"Error generating icons: {e}") - return None - -if __name__ == "__main__": - logging.basicConfig(level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(name)s - %(message)s') - - source_file = None - output_dir = None - - if len(sys.argv) > 1: - source_file = Path(sys.argv[1]) - if len(sys.argv) > 2: - output_dir = Path(sys.argv[2]) - - generated = generate_icons(source_file=source_file, output_dir=output_dir) - - if generated: - print(f"Successfully generated {len(generated)} icon variants") - print("Icon paths:") - for name, path in generated.items(): - print(f" {name}: {path}") - else: - print("Failed to generate icons. Check the logs for details.") - sys.exit(1) \ No newline at end of file diff --git a/simkl_mps/utils/updater.ps1 b/simkl_mps/utils/updater.ps1 new file mode 100644 index 0000000..5e7ee24 --- /dev/null +++ b/simkl_mps/utils/updater.ps1 @@ -0,0 +1,467 @@ +# updater.ps1 +# PowerShell script for checking and installing updates for Media Player Scrobbler for SIMKL +# This script is called by the Inno Setup installer and can also be run manually or on schedule + +param ( + [switch]$Silent = $false, + [switch]$Force = $false, + [switch]$CheckOnly = $false +) + +# Constants +$AppName = "Media Player Scrobbler for SIMKL" +$Publisher = "kavinthangavel" +$RepoURL = "https://github.com/kavinthangavel/simkl-movie-tracker" +$ReleasesURL = "https://github.com/kavinthangavel/simkl-movie-tracker/releases" +$ApiURL = "https://api.github.com/repos/kavinthangavel/simkl-movie-tracker/releases/latest" +$UserAgent = "MPSS-Updater/1.0" +$LogFile = Join-Path $env:LOCALAPPDATA "SIMKL-MPS\updater.log" + +# Ensure log directory exists +$LogDir = Split-Path $LogFile -Parent +if (-not (Test-Path $LogDir)) { + New-Item -ItemType Directory -Path $LogDir -Force | Out-Null +} + +# Helper function to log messages +function Write-Log { + param([string]$Message) + + $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $LogMessage = "[$Timestamp] $Message" + + if (-not $Silent) { + Write-Host $LogMessage + } + + Add-Content -Path $LogFile -Value $LogMessage +} + +# Get current version from registry +function Get-CurrentVersion { + $RegPath = "HKCU:\Software\$Publisher\$AppName" + + if (Test-Path $RegPath) { + $Version = (Get-ItemProperty -Path $RegPath -Name "Version" -ErrorAction SilentlyContinue).Version + if ($Version) { + return $Version + } + } + + # Try to get version from uninstall registry + $UninstallPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1" + if (Test-Path $UninstallPath) { + $Version = (Get-ItemProperty -Path $UninstallPath -Name "DisplayVersion" -ErrorAction SilentlyContinue).DisplayVersion + if ($Version) { + return $Version + } + } + + # Admin installation check + $AdminUninstallPath = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1" + if (Test-Path $AdminUninstallPath) { + $Version = (Get-ItemProperty -Path $AdminUninstallPath -Name "DisplayVersion" -ErrorAction SilentlyContinue).DisplayVersion + if ($Version) { + return $Version + } + } + + return "0.0.0" +} + +# Get installation path from registry +function Get-InstallationPath { + $RegPath = "HKCU:\Software\$Publisher\$AppName" + + if (Test-Path $RegPath) { + $InstallPath = (Get-ItemProperty -Path $RegPath -Name "InstallPath" -ErrorAction SilentlyContinue).InstallPath + if ($InstallPath -and (Test-Path $InstallPath)) { + return $InstallPath + } + } + + # Try to get path from uninstall registry + $UninstallPath = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1" + if (Test-Path $UninstallPath) { + $InstallPath = (Get-ItemProperty -Path $UninstallPath -Name "InstallLocation" -ErrorAction SilentlyContinue).InstallLocation + if ($InstallPath -and (Test-Path $InstallPath)) { + return $InstallPath + } + } + + # Admin installation check + $AdminUninstallPath = "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall\{3FF84A4E-B9C2-4F49-A8DE-5F7EA15F5D88}_is1" + if (Test-Path $AdminUninstallPath) { + $InstallPath = (Get-ItemProperty -Path $AdminUninstallPath -Name "InstallLocation" -ErrorAction SilentlyContinue).InstallLocation + if ($InstallPath -and (Test-Path $InstallPath)) { + return $InstallPath + } + } + + return $null +} + +# Check if automatic updates are enabled +function Is-AutoUpdateEnabled { + $RegPath = "HKCU:\Software\$Publisher\$AppName" + + if (Test-Path $RegPath) { + $AutoUpdate = (Get-ItemProperty -Path $RegPath -Name "AutoUpdate" -ErrorAction SilentlyContinue).AutoUpdate + if ($null -ne $AutoUpdate) { + return [bool]$AutoUpdate + } + } + + return $false +} + +# Compare version strings +function Compare-Versions { + param([string]$Version1, [string]$Version2) + + try { + $V1 = [System.Version]::Parse($Version1) + $V2 = [System.Version]::Parse($Version2) + + return $V1.CompareTo($V2) + } + catch { + Write-Log "Error comparing versions: $_" + # If we can't parse as System.Version, do a string comparison + return [string]::Compare($Version1, $Version2, $true) + } +} + +# Check GitHub for the latest release +function Get-LatestReleaseInfo { + Write-Log "Checking for updates..." + + try { + # Set TLS 1.2 for HTTPS connections + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + $Headers = @{ + "User-Agent" = $UserAgent + } + + $Response = Invoke-RestMethod -Uri $ApiURL -Headers $Headers -Method Get + + if ($Response.tag_name) { + # Clean up version string (remove leading 'v' if present) + $Version = $Response.tag_name -replace '^v', '' + + $ReleaseInfo = @{ + Version = $Version + PublishedAt = $Response.published_at + Name = $Response.name + Body = $Response.body + DownloadUrl = $null + } + + # Find the Windows installer asset + foreach ($Asset in $Response.assets) { + if ($Asset.name -like "*Setup*.exe") { + $ReleaseInfo.DownloadUrl = $Asset.browser_download_url + break + } + } + + return $ReleaseInfo + } + } + catch { + Write-Log "Error checking for updates: $_" + } + + return $null +} + +# Download the installer to a temporary location +function Download-Installer { + param([string]$Url) + + try { + $TempFile = [System.IO.Path]::GetTempFileName() + ".exe" + + Write-Log "Downloading update to $TempFile..." + + # Set TLS 1.2 for HTTPS connections + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + + $WebClient = New-Object System.Net.WebClient + $WebClient.Headers.Add("User-Agent", $UserAgent) + $WebClient.DownloadFile($Url, $TempFile) + + Write-Log "Download completed." + return $TempFile + } + catch { + Write-Log "Error downloading update: $_" + return $null + } +} + +# Stop running applications before update +function Stop-RunningApps { + try { + $Processes = @("MPSS", "MPS for Simkl") + + foreach ($Process in $Processes) { + $Running = Get-Process -Name $Process -ErrorAction SilentlyContinue + if ($Running) { + Write-Log "Stopping $Process..." + Stop-Process -Name $Process -Force + Start-Sleep -Seconds 2 + } + } + return $true + } + catch { + Write-Log "Error stopping applications: $_" + return $false + } +} + +# Update the "last check" timestamp in registry +function Update-LastCheckTimestamp { + $RegPath = "HKCU:\Software\$Publisher\$AppName" + + if (-not (Test-Path $RegPath)) { + New-Item -Path $RegPath -Force | Out-Null + } + + $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + Set-ItemProperty -Path $RegPath -Name "LastUpdateCheck" -Value $Timestamp +} + +# Display a Windows notification +function Show-Notification { + param ( + [string]$Title, + [string]$Message + ) + + try { + # Load required assemblies for Windows 10 style notifications + [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null + [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null + + # Get the template + $Template = [Windows.UI.Notifications.ToastTemplateType]::ToastText02 + $XmlDocument = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent($Template) + + # Set the title and message in the notification + $TextElements = $XmlDocument.GetElementsByTagName("text") + $TextElements[0].AppendChild($XmlDocument.CreateTextNode($Title)) | Out-Null + $TextElements[1].AppendChild($XmlDocument.CreateTextNode($Message)) | Out-Null + + # Create the notification + $Toast = [Windows.UI.Notifications.ToastNotification]::new($XmlDocument) + $Toast.Tag = "MPSS-Update" + + # Show the notification using the "MPS for SIMKL" app name + $Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("MPS for SIMKL") + $Notifier.Show($Toast) + } + catch { + # Fallback to PowerShell legacy notification method + try { + Add-Type -AssemblyName System.Windows.Forms + $notification = New-Object System.Windows.Forms.NotifyIcon + $notification.Icon = [System.Drawing.Icon]::ExtractAssociatedIcon((Get-Command powershell).Path) + $notification.BalloonTipIcon = "Info" + $notification.BalloonTipTitle = $Title + $notification.BalloonTipText = $Message + $notification.Visible = $true + $notification.ShowBalloonTip(10000) + } + catch { + # If notifications fail, just log it + Write-Log "Could not show notification: $_" + } + } +} + +# Run the installer with correct parameters +function Run-Installer { + param([string]$InstallerPath) + + try { + Write-Log "Running installer: $InstallerPath" + + $Arguments = "/SILENT /SUPPRESSMSGBOXES /NORESTART" + + # For silent installations, we want to use the same installation dir + $InstallDir = Get-InstallationPath + if ($InstallDir) { + $Arguments += " /DIR=`"$InstallDir`"" + } + + # Add tasks to preserve the user's choices + $RegPath = "HKCU:\Software\$Publisher\$AppName" + if (Test-Path $RegPath) { + $AutoUpdate = (Get-ItemProperty -Path $RegPath -Name "AutoUpdate" -ErrorAction SilentlyContinue).AutoUpdate + if ($AutoUpdate -eq 1) { + $Arguments += " /TASKS=`"autoupdate`"" + } + } + + # Log the command + Write-Log "Running: Start-Process -FilePath '$InstallerPath' -ArgumentList '$Arguments' -Wait" + + # Execute the installer + $Process = Start-Process -FilePath $InstallerPath -ArgumentList $Arguments -Wait -PassThru + $ExitCode = $Process.ExitCode + + Write-Log "Installer completed with exit code: $ExitCode" + + if ($ExitCode -eq 0) { + Show-Notification "Update Successful" "Media Player Scrobbler for SIMKL has been updated successfully." + return $true + } + else { + Write-Log "Update failed with exit code $ExitCode" + Show-Notification "Update Failed" "The update could not be completed. Exit code: $ExitCode" + return $false + } + } + catch { + Write-Log "Error running installer: $_" + Show-Notification "Update Error" "An error occurred during the update: $_" + return $false + } +} + +# Main update check and installation logic +function Check-And-Install-Update { + param([switch]$ForceInstall = $false) + + # Get current version + $CurrentVersion = Get-CurrentVersion + Write-Log "Current version: $CurrentVersion" + + # Check for updates + $LatestRelease = Get-LatestReleaseInfo + + if ($null -eq $LatestRelease) { + Write-Log "Failed to check for updates." + if (-not $Silent) { + Show-Notification "Update Check Failed" "Could not check for updates. Please try again later." + } + return $false + } + + Write-Log "Latest version: $($LatestRelease.Version)" + + # Compare versions + $CompareResult = Compare-Versions -Version1 $LatestRelease.Version -Version2 $CurrentVersion + + if ($CompareResult -le 0 -and -not $ForceInstall) { + Write-Log "Already running the latest version." + if (-not $Silent) { + Show-Notification "No Updates Available" "You are already running the latest version ($CurrentVersion)." + } + return $true + } + + # If this is only a check, notify about available update and exit + if ($CheckOnly) { + Write-Log "Update available: $($LatestRelease.Version)" + Show-Notification "Update Available" "Version $($LatestRelease.Version) is available. Current version: $CurrentVersion" + return $true + } + + # Confirm update if not silent or forced + if (-not $Silent -and -not $ForceInstall) { + $Confirmation = [System.Windows.Forms.MessageBox]::Show( + "A new version ($($LatestRelease.Version)) of Media Player Scrobbler for SIMKL is available.`n`nCurrent version: $CurrentVersion`n`nDo you want to update now?", + "Update Available", + [System.Windows.Forms.MessageBoxButtons]::YesNo, + [System.Windows.Forms.MessageBoxIcon]::Question + ) + + if ($Confirmation -ne [System.Windows.Forms.DialogResult]::Yes) { + Write-Log "Update canceled by user." + return $false + } + } + + # Check for download URL + if (-not $LatestRelease.DownloadUrl) { + Write-Log "No download URL found for the latest release." + if (-not $Silent) { + Show-Notification "Update Error" "Could not find the download URL for the latest version." + } + return $false + } + + # Download the installer + $InstallerPath = Download-Installer -Url $LatestRelease.DownloadUrl + + if (-not $InstallerPath) { + Write-Log "Failed to download the installer." + if (-not $Silent) { + Show-Notification "Update Error" "Could not download the update. Please try again later." + } + return $false + } + + # Stop running instances + $Stopped = Stop-RunningApps + + if (-not $Stopped) { + Write-Log "Failed to stop running applications." + if (-not $Silent) { + Show-Notification "Update Warning" "Could not stop all running instances. Update may fail." + } + } + + # Run the installer + $Result = Run-Installer -InstallerPath $InstallerPath + + # Clean up the temporary file + if (Test-Path $InstallerPath) { + Remove-Item -Path $InstallerPath -Force + } + + return $Result +} + +# Main execution +try { + # Add necessary .NET assembly for Windows Forms + Add-Type -AssemblyName System.Windows.Forms + + Write-Log "========================================" + Write-Log "MPSS Updater started with parameters:" + Write-Log " Silent: $Silent" + Write-Log " Force: $Force" + Write-Log " CheckOnly: $CheckOnly" + + # Update the "last check" timestamp + Update-LastCheckTimestamp + + # Run the update check + $Result = Check-And-Install-Update -ForceInstall $Force + + if ($Result) { + Write-Log "Update process completed successfully." + exit 0 + } + else { + Write-Log "Update process completed with errors." + exit 1 + } +} +catch { + Write-Log "Unhandled exception in updater: $_" + if (-not $Silent) { + [System.Windows.Forms.MessageBox]::Show( + "An unexpected error occurred during the update process: $_", + "Update Error", + [System.Windows.Forms.MessageBoxButtons]::OK, + [System.Windows.Forms.MessageBoxIcon]::Error + ) + } + exit 1 +} \ No newline at end of file diff --git a/simkl_mps/utils/updater.sh b/simkl_mps/utils/updater.sh new file mode 100644 index 0000000..2078296 --- /dev/null +++ b/simkl_mps/utils/updater.sh @@ -0,0 +1,368 @@ +#!/bin/bash + +# updater.sh - Update script for macOS and Linux +# Checks for updates and manages installation of Media Player Scrobbler for SIMKL + +# Configuration +APP_NAME="MPS for SIMKL" +REPO="kavinthangavel/simkl-movie-tracker" +USER_AGENT="MPSS-Updater/1.0" +CONFIG_DIR="${HOME}/.config/simkl-mps" +LOG_FILE="${CONFIG_DIR}/updater.log" +SILENT=false +FORCE=false +FIRST_RUN_FILE="${CONFIG_DIR}/first_run" + +# Ensure config directory exists +mkdir -p "${CONFIG_DIR}" + +# Logging function +log_message() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}" +} + +# Function to show a desktop notification +show_notification() { + TITLE="$1" + MESSAGE="$2" + + # Determine OS + if [[ "$(uname)" == "Darwin" ]]; then + # macOS + osascript -e "display notification \"$MESSAGE\" with title \"$TITLE\"" + else + # Linux - use various notification methods + if command -v notify-send &> /dev/null; then + # Use notify-send if available (most Linux distros) + ICON_PATH="" + if [ -f "${HOME}/.local/share/icons/simkl-mps.png" ]; then + ICON_PATH="${HOME}/.local/share/icons/simkl-mps.png" + elif [ -f "/usr/share/icons/simkl-mps.png" ]; then + ICON_PATH="/usr/share/icons/simkl-mps.png" + elif [ -f "/usr/share/pixmaps/simkl-mps.png" ]; then + ICON_PATH="/usr/share/pixmaps/simkl-mps.png" + fi + + if [ -n "$ICON_PATH" ]; then + notify-send -i "$ICON_PATH" "$TITLE" "$MESSAGE" + else + notify-send "$TITLE" "$MESSAGE" + fi + elif command -v zenity &> /dev/null; then + # Fallback to zenity + zenity --notification --text="$TITLE: $MESSAGE" + else + # Text-only fallback + echo "$TITLE: $MESSAGE" >&2 + fi + fi +} + +# Show first run message if applicable +check_first_run() { + if [ -f "$FIRST_RUN_FILE" ]; then + log_message "First run detected" + + # Determine the platform + if [[ "$(uname)" == "Darwin" ]]; then + # macOS - use dialog + osascript -e 'display dialog "Welcome to Media Player Scrobbler for SIMKL!\n\nThis app will automatically check for updates once a week.\n\nYou can manually check for updates from the app menu." buttons {"OK"} default button "OK" with title "MPSS Auto-Update"' + else + # Linux - use zenity if available + if command -v zenity &> /dev/null; then + zenity --info --title="MPSS Auto-Update" --text="Welcome to Media Player Scrobbler for SIMKL!\n\nThis app will automatically check for updates once a week.\n\nYou can manually check for updates from the app menu." + else + # Fall back to notification + show_notification "MPSS Auto-Update" "Weekly update checks are enabled. You can manually check from the app menu." + fi + fi + + # Remove first run file + rm -f "$FIRST_RUN_FILE" + fi +} + +# Get current version +get_current_version() { + if [ -f "${CONFIG_DIR}/version.txt" ]; then + cat "${CONFIG_DIR}/version.txt" + else + echo "0.0.0" # Default if version file doesn't exist + fi +} + +# Get latest release info from GitHub +get_latest_release() { + local api_url="https://api.github.com/repos/${REPO}/releases/latest" + + if command -v curl &> /dev/null; then + curl -s -A "${USER_AGENT}" "${api_url}" + elif command -v wget &> /dev/null; then + wget -q -O- --header="User-Agent: ${USER_AGENT}" "${api_url}" + else + log_message "Error: Neither curl nor wget is installed" + return 1 + fi +} + +# Parse version from semantic version string (e.g., "v1.2.3" -> "1.2.3") +parse_version() { + echo "$1" | sed 's/^v//' +} + +# Compare versions (returns 1 if version1 > version2, 0 otherwise) +compare_versions() { + local version1="$1" + local version2="$2" + + # Use sort for version comparison + if [ "$(echo -e "${version1}\n${version2}" | sort -V | head -n1)" = "${version2}" ]; then + echo 1 + else + echo 0 + fi +} + +# Download and install the update +install_update() { + local download_url="$1" + local version="$2" + local temp_dir + + # Create temporary directory + temp_dir=$(mktemp -d) + log_message "Created temporary directory: ${temp_dir}" + + # Download the file + local filename + + if [[ "$(uname)" == "Darwin" ]]; then + filename="MPSS_macOS.dmg" + else + filename="MPSS_Linux.tar.gz" + fi + + local download_path="${temp_dir}/${filename}" + + log_message "Downloading update from: ${download_url}" + + if command -v curl &> /dev/null; then + curl -L -A "${USER_AGENT}" -o "${download_path}" "${download_url}" + elif command -v wget &> /dev/null; then + wget -q --header="User-Agent: ${USER_AGENT}" -O "${download_path}" "${download_url}" + else + log_message "Error: Neither curl nor wget is installed" + return 1 + fi + + if [ ! -f "${download_path}" ]; then + log_message "Download failed" + show_notification "Update Error" "Failed to download the update" + return 1 + fi + + log_message "Download completed: ${download_path}" + + # Stop running applications + pkill -f "MPSS" || true + pkill -f "MPS for SIMKL" || true + + # Install based on platform + if [[ "$(uname)" == "Darwin" ]]; then + # macOS - mount DMG and copy app + local mount_point="/Volumes/MPSS" + log_message "Mounting DMG: ${download_path}" + hdiutil attach "${download_path}" -mountpoint "${mount_point}" + + if [ -d "${mount_point}/MPSS.app" ]; then + log_message "Installing application to /Applications" + cp -R "${mount_point}/MPSS.app" "/Applications/" + # Update version file + echo "${version}" > "${CONFIG_DIR}/version.txt" + show_notification "Update Successful" "Media Player Scrobbler for SIMKL has been updated to version ${version}" + else + log_message "Error: Application not found in DMG" + show_notification "Update Failed" "Could not find the application in the downloaded package" + fi + + # Unmount DMG + hdiutil detach "${mount_point}" -force + else + # Linux - extract tar.gz + local extract_dir="${temp_dir}/extract" + mkdir -p "${extract_dir}" + + log_message "Extracting archive: ${download_path}" + tar -xzf "${download_path}" -C "${extract_dir}" + + # Find the executable + if [ -f "${extract_dir}/MPSS" ]; then + log_message "Installing to ${HOME}/.local/bin" + mkdir -p "${HOME}/.local/bin" + cp "${extract_dir}/MPSS" "${HOME}/.local/bin/" + cp "${extract_dir}/MPS for SIMKL" "${HOME}/.local/bin/" + + # Copy other required files + if [ -d "${extract_dir}/lib" ]; then + mkdir -p "${HOME}/.local/lib/simkl-mps" + cp -R "${extract_dir}/lib/" "${HOME}/.local/lib/simkl-mps/" + fi + + # Copy icons if present + if [ -d "${extract_dir}/icons" ]; then + mkdir -p "${HOME}/.local/share/icons" + cp -R "${extract_dir}/icons/" "${HOME}/.local/share/icons/" + fi + + # Make executables... executable + chmod +x "${HOME}/.local/bin/MPSS" + chmod +x "${HOME}/.local/bin/MPS for SIMKL" + + # Update version file + echo "${version}" > "${CONFIG_DIR}/version.txt" + show_notification "Update Successful" "Media Player Scrobbler for SIMKL has been updated to version ${version}" + else + log_message "Error: Executable not found in archive" + show_notification "Update Failed" "Could not find the application in the downloaded package" + fi + fi + + # Clean up + rm -rf "${temp_dir}" + log_message "Cleanup completed" +} + +# Main update function +check_and_install_update() { + log_message "Checking for updates..." + + # Get current version + local current_version + current_version=$(get_current_version) + log_message "Current version: ${current_version}" + + # Get latest release info from GitHub + local release_info + release_info=$(get_latest_release) + + if [ -z "${release_info}" ]; then + log_message "Failed to get latest release info" + show_notification "Update Check Failed" "Could not check for updates. Please try again later." + return 1 + fi + + # Extract version and download URL from release info + local latest_version + latest_version=$(echo "${release_info}" | grep '"tag_name":' | sed -E 's/.*"tag_name": *"([^"]+)".*/\1/') + latest_version=$(parse_version "${latest_version}") + + log_message "Latest version: ${latest_version}" + + # Compare versions + if [ "$(compare_versions "${latest_version}" "${current_version}")" -eq 0 ] && [ "${FORCE}" != "true" ]; then + log_message "Already running the latest version" + if [ "${SILENT}" != "true" ]; then + show_notification "No Updates Available" "You are already running the latest version (${current_version})." + fi + return 0 + fi + + log_message "Update available: ${latest_version}" + + # If silent, just notify about the update + if [ "${SILENT}" = "true" ]; then + show_notification "Update Available" "Version ${latest_version} is available. Current version: ${current_version}" + return 0 + fi + + # Get the appropriate download URL based on the platform + local download_url + + if [[ "$(uname)" == "Darwin" ]]; then + # macOS + download_url=$(echo "${release_info}" | grep -o '"browser_download_url": *"[^"]*\.dmg"' | head -n 1 | sed -E 's/.*"browser_download_url": *"([^"]+)".*/\1/') + else + # Linux + download_url=$(echo "${release_info}" | grep -o '"browser_download_url": *"[^"]*\.tar\.gz"' | head -n 1 | sed -E 's/.*"browser_download_url": *"([^"]+)".*/\1/') + fi + + if [ -z "${download_url}" ]; then + log_message "Could not find download URL for the latest version" + show_notification "Update Error" "Could not find the download URL for the latest version" + return 1 + fi + + log_message "Download URL: ${download_url}" + + # Ask for confirmation if not forced + if [ "${FORCE}" != "true" ]; then + if [[ "$(uname)" == "Darwin" ]]; then + # macOS dialog + osascript -e "display dialog \"A new version (${latest_version}) of Media Player Scrobbler for SIMKL is available.\n\nCurrent version: ${current_version}\n\nDo you want to update now?\" buttons {\"Later\", \"Update Now\"} default button \"Update Now\" with title \"Update Available\"" + if [ $? -ne 0 ]; then + log_message "Update canceled by user" + return 0 + fi + else + # Linux dialog + if command -v zenity &> /dev/null; then + zenity --question --title="Update Available" --text="A new version (${latest_version}) of Media Player Scrobbler for SIMKL is available.\n\nCurrent version: ${current_version}\n\nDo you want to update now?" + if [ $? -ne 0 ]; then + log_message "Update canceled by user" + return 0 + fi + else + # Use terminal if GUI is not available + read -p "A new version (${latest_version}) is available. Do you want to update now? (y/N) " response + case "$response" in + [yY][eE][sS]|[yY]) + # Continue with update + ;; + *) + log_message "Update canceled by user" + return 0 + ;; + esac + fi + fi + fi + + # Download and install the update + install_update "${download_url}" "${latest_version}" +} + +# Parse command line arguments +while [[ $# -gt 0 ]]; do + case "$1" in + -s|--silent) + SILENT=true + shift + ;; + -f|--force) + FORCE=true + shift + ;; + --check-first-run) + check_first_run + exit 0 + ;; + *) + log_message "Unknown option: $1" + shift + ;; + esac +done + +# Update the "last check" timestamp +mkdir -p "${CONFIG_DIR}" +date +%s > "${CONFIG_DIR}/last_update_check" + +# Check for first run +check_first_run + +# Run the update check +log_message "MPSS Updater started" +check_and_install_update + +log_message "Update check completed" +exit 0