├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── cli-app-request.md └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── MANUELINSTALL.MD ├── README.MD ├── assets └── MediaHarborBanner.svg ├── package-lock.json ├── package.json ├── resources ├── appx │ ├── LargeTile.png │ ├── SmallTile.png │ ├── SplashScreen.png │ ├── Square150x150Logo.png │ ├── Square44x44Logo.png │ ├── StoreLogo.png │ └── Wide310x150Logo.png ├── icon.png ├── icons │ ├── 1024x1024.png │ ├── 128x128.png │ ├── 16x16.png │ ├── 256x256.png │ ├── 32x32.png │ ├── 512x512.png │ └── 64x64.png ├── mediaharbor.apparmor └── postinst.sh └── src ├── assets ├── HiRes.jpg ├── MediaHarbor_Logo.svg └── placeholder.png ├── firststart.css ├── funcs ├── apis │ ├── applemusicapi.py │ ├── deezerapi.py │ ├── qobuzapi.py │ ├── spotifyapi.py │ ├── tidalapi.py │ ├── ytaudiostream.py │ ├── ytmusicsearchapi.py │ ├── ytsearchapi.py │ └── ytvideostream.py ├── customRip.js ├── db.js ├── defaults.js ├── downloadorder.js ├── fetchers.js ├── gamRip.js ├── installers │ ├── bento4installer.js │ ├── ffmpegInstaller.js │ ├── gitInstaller.js │ └── pythonInstaller.js ├── settings.js ├── spawner.js ├── updatechecker.js └── yt_dlp_downloaders.js ├── main.js ├── pages ├── app.js ├── downloads.html ├── firststart.html ├── firststart.js ├── help.html ├── index.html ├── music.html ├── placeholder.png ├── search.html ├── settings.html └── video.html ├── preload.js ├── start.py └── style.css /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: ["https://blockchair.com/bitcoin/address/bc1qywkchl7jyw8mevg78dtyxzszay98sagajwgdcw"] 16 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "[BUG]" 5 | labels: bug 6 | assignees: CrossyAtom46 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Desktop (please complete the following information):** 27 | - OS: [e.g. iOS] 28 | - Version [e.g. 22] 29 | 30 | **Logs** 31 | To get logs start MediaHarbor from terminal. 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/cli-app-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: CLI App Request 3 | about: Request to add a CLI tool to MediaHarbor 4 | title: 'Add CLI Tool Request: [Tool Name]' 5 | labels: cli 6 | assignees: CrossyAtom46 7 | 8 | --- 9 | 10 | ## CLI Tool Request: [Tool Name] 11 | 12 | ### Tool Repository 13 | - **GitHub Repository**: [Insert GitHub repo link here] 14 | 15 | ### Purpose of the Tool 16 | - **Description**: Explain what this tool does. 17 | 18 | ### Usage Examples 19 | Provide a few examples of how this tool can be used. Include sample commands, descriptions, and potential output. Example format: 20 | - **Example Command**: 21 | ```bash 22 | command --with options 23 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | paths: 7 | - 'package.json' 8 | pull_request: 9 | branches: ["main"] 10 | paths: 11 | - 'package.json' 12 | 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | fetch-depth: 0 21 | 22 | - name: Get Version from package.json 23 | id: package_version 24 | run: | 25 | VERSION=$(node -p "require('./package.json').version") 26 | echo "VERSION=v${VERSION}" >> $GITHUB_ENV 27 | 28 | - name: Check if tag exists 29 | id: check_tag 30 | run: | 31 | if git rev-parse "${{ env.VERSION }}" >/dev/null 2>&1; then 32 | echo "Tag already exists, skipping release" 33 | echo "TAG_EXISTS=true" >> $GITHUB_ENV 34 | else 35 | echo "TAG_EXISTS=false" >> $GITHUB_ENV 36 | fi 37 | 38 | - name: Get Changelog 39 | if: env.TAG_EXISTS == 'false' 40 | id: changelog 41 | run: | 42 | PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "") 43 | if [ -z "$PREV_TAG" ]; then 44 | CHANGELOG=$(git log --pretty=format:'- %s') 45 | else 46 | CHANGELOG=$(git log --pretty=format:'- %s' ${PREV_TAG}..HEAD) 47 | fi 48 | echo "CHANGELOG<> $GITHUB_ENV 49 | echo "$CHANGELOG" >> $GITHUB_ENV 50 | echo "EOF" >> $GITHUB_ENV 51 | 52 | - name: Create Git Tag 53 | if: env.TAG_EXISTS == 'false' 54 | run: | 55 | git tag ${{ env.VERSION }} 56 | git push origin ${{ env.VERSION }} 57 | 58 | - name: Create GitHub Release 59 | if: env.TAG_EXISTS == 'false' 60 | uses: softprops/action-gh-release@v2.0.8 61 | with: 62 | tag_name: ${{ env.VERSION }} 63 | body: | 64 | ## Changelog 65 | ${{ env.CHANGELOG }} 66 | prerelease: true 67 | env: 68 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | node_modules 3 | # Default ignored files 4 | /shelf/ 5 | /workspace.xml 6 | # Editor-based HTTP Client requests 7 | /httpRequests/ 8 | 9 | src/funcs/apis/apis.json 10 | /dist/* 11 | /build/* 12 | -------------------------------------------------------------------------------- /MANUELINSTALL.MD: -------------------------------------------------------------------------------- 1 | # Manual Installation Guide 2 | 3 | I apologize for the first start problems, please create a new issue with details about your operating system. 4 | 5 | ## Dependencies and PIP Packages 6 | 7 | | Component | Official Website / Repository | Example Installation | 8 | |------------------------------------------|----------------------------------------------------------|---------------------------------------------------------------------------| 9 | | **Python** (3.9 - 3.12) | [python.org](https://www.python.org/) | Download and install from [python.org](https://www.python.org/) | 10 | | **FFmpeg** | [ffmpeg.org](https://ffmpeg.org/) | Follow installation instructions on [ffmpeg.org](https://ffmpeg.org/) | 11 | | **Git** | [git-scm.com](https://git-scm.com/) | Download and install from [git-scm.com](https://git-scm.com/) | 12 | | **yt-dlp** (YouTube) | [yt-dlp GitHub](https://github.com/yt-dlp/yt-dlp) | `pip install yt-dlp` | 13 | | **youtubemusicapi** (YTMusic Search) | [youtubemusicapi GitHub](https://github.com/sigma67/youtube-music-api) | `pip install youtubemusicapi` | 14 | | **custom_streamrip** (Qobuz) | [custom_streamrip](https://github.com/mediaharbor/custom_streamrip.git) | `pip install git+https://github.com/mediaharbor/custom_streamrip.git` | 15 | | **custom_streamrip** (Deezer) | [custom_streamrip](https://github.com/mediaharbor/custom_streamrip.git) | `pip install git+https://github.com/mediaharbor/custom_streamrip.git` | 16 | | **custom_streamrip** (Tidal) | [custom_streamrip](https://github.com/mediaharbor/custom_streamrip.git) | `pip install git+https://github.com/mediaharbor/custom_streamrip.git` | 17 | | **custom_votify** (Spotify) | [custom_votify GitHub](https://github.com/mediaharbor/custom_votify) | `pip install git+https://github.com/mediaharbor/custom_votify.git` | 18 | | **custom_gamdl** (Apple Music) | [custom_gamdl GitHub](https://github.com/mediaharbor/custom_gamdl) | `pip install git+https://github.com/mediaharbor/custom_gamdl.git` | 19 | | **pyapplemusicapi** (Apple Music Search) | [pyapplemusicapi GitHub](https://github.com/queengooborg/pyapplemusicapi) | `pip install pyapplemusicapi` | 20 | | **Bento4** (MP4Decrypt) | [Bento4 Binaries](https://www.bok.net/Bento4/binaries/?C=M;O=D) | Download and extract manually, then add to system path | 21 | 22 | ## Why custom_? 23 | Because their downloaders don't support new line downloaders, I modified them. If they add new line progress bars to their CLI (like yt-dlp), we can use them. 24 | -------------------------------------------------------------------------------- /README.MD: -------------------------------------------------------------------------------- 1 | ![Logo](assets/MediaHarborBanner.svg) 2 | 3 | # MediaHarbor Desktop 4 | 5 | ![License](https://img.shields.io/github/license/MediaHarbor/MediaHarbor) ![Build Status](https://img.shields.io/github/actions/workflow/status/MediaHarbor/MediaHarbor/node.js.yml) ![Downloads](https://img.shields.io/github/downloads/MediaHarbor/MediaHarbor/latest/total) 6 | ![Static Badge](https://img.shields.io/badge/website-mediaharbor?style=for-the-badge&logo=github&label=MediaHarbor&link=https%3A%2F%2Fmediaharbor.github.io) 7 | 8 | MediaHarbor is your all-in-one media downloader, supporting audio and video downloading / streaming from platforms like YouTube and more—all through an intuitive, streamlined interface. 9 | 10 | ## Installation 11 | You can download and install MediaHarbor from releases page for your OS 12 | 13 | ## Table of Contents 14 | - [Features](#features) 15 | - [Known Issues](#known-issues) 16 | - [Installation](#installation) 17 | - [Usage](#usage) 18 | - [Contributing](#contributing) 19 | - [License](#license) 20 | 21 | ## Features 22 | - Download audio & video from popular platforms 23 | - Seamless integration with yt-dlp 24 | - Built-in search across YouTube, Spotify, and more 25 | - Editing configuration files in app 26 | 27 | ## Known Issues 28 | 29 | - Batch downloads can flicker until they finish. 30 | - Play buttons on search page are limited to Videos, Tracks, and Podcasts only. 31 | - Can't handle all errors on Downloading. 32 | 33 | ## Building 34 | 35 | ### Prerequisites 36 | - Node.js 37 | - npm 38 | 39 | ### Clone the Repository 40 | 41 | ```bash 42 | git clone https://github.com/MediaHarbor/mediaharbor.git 43 | cd mediaharbor 44 | ``` 45 | 46 | ### Install Dependencies 47 | 48 | ```bash 49 | npm install 50 | ``` 51 | 52 | 53 | ## Usage 54 | 55 | To start the application, run: 56 | 57 | ```bash 58 | npm run start 59 | ``` 60 | 61 | To build the application: 62 | 63 | ```bash 64 | npm run build 65 | ``` 66 | ## Manual installation for first start 67 | You can find which packages MH currently uses at [MANUELINSTALL.MD](MANUELINSTALL.MD). 68 | ## Contributing 69 | All contributions are welcome! Please read the contributing guidelines in the [CONTRIBUTING.md](CONTRIBUTING.md) file. 70 | 71 | ## License 72 | This project is licensed under the GPL 3.0 - see the [LICENSE](LICENSE) file for details. 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mediaharbor", 3 | "version": "1.1.0", 4 | "main": "src/main.js", 5 | "scripts": { 6 | "start": "electron .", 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "electron-builder", 9 | "build:win": "electron-builder --win", 10 | "build:mac": "electron-builder --mac", 11 | "build:linux": "electron-builder --linux" 12 | }, 13 | "keywords": [], 14 | "author": "CrossyAtom46", 15 | "license": "GPL-3.0-or-later", 16 | "description": "MediaHarbor", 17 | "homepage": "https://mediaharbor.github.io", 18 | "build": { 19 | "protocols": [ 20 | { 21 | "name": "MediaHarbor Protocol", 22 | "schemes": [ 23 | "mediaharbor" 24 | ] 25 | } 26 | ], 27 | "appId": "AtomDev.MediaHarbor", 28 | "productName": "MediaHarbor", 29 | "asarUnpack": [ 30 | "src/funcs/apis/**/*.py", 31 | "src/*.py", 32 | "src/funcs/apis/apis.json" 33 | ], 34 | "files": [ 35 | "src/**/*", 36 | "package.json" 37 | ], 38 | "directories": { 39 | "buildResources": "resources", 40 | "output": "dist" 41 | }, 42 | "win": { 43 | "target": [ 44 | "nsis", 45 | "portable", 46 | "appx" 47 | ], 48 | "icon": "resources/icon.png" 49 | }, 50 | "appx": { 51 | "applicationId": "AtomDev.MediaHarbor", 52 | "backgroundColor": "#464646", 53 | "identityName": "AtomDev.MediaHarbor", 54 | "publisher": "CN=B799C26F-32FE-4398-8D3A-A8667FA3C70A", 55 | "publisherDisplayName": "AtomDev", 56 | "languages": [ 57 | "en-US" 58 | ], 59 | "displayName": "MediaHarbor", 60 | "showNameOnTiles": false, 61 | "artifactName": "MediaHarbor-${version}-${arch}.${ext}" 62 | }, 63 | "mac": { 64 | "target": [ 65 | "dmg" 66 | ], 67 | "icon": "resources/icon.png" 68 | }, 69 | "linux": { 70 | "target": [ 71 | "AppImage", 72 | "deb", 73 | "snap" 74 | ], 75 | "maintainer": "AtomDev", 76 | "icon": "resources/icons/", 77 | "category": "Utility", 78 | "executableName": "mediaharbor", 79 | "artifactName": "${productName}-${version}.${ext}", 80 | "desktop": { 81 | "Name": "MediaHarbor", 82 | "Comment": "MediaHarbor Application", 83 | "Categories": "Utility;", 84 | "Type": "Application" 85 | }, 86 | "publish": [ 87 | "github" 88 | ] 89 | }, 90 | "deb": { 91 | "afterInstall": "resources/postinst.sh" 92 | }, 93 | "snap": { 94 | "grade": "stable", 95 | "confinement": "strict", 96 | "plugs": [ 97 | "network", 98 | "home", 99 | "process-control" 100 | ], 101 | "environment": { 102 | "ELECTRON_ENABLE_LOGGING": "true", 103 | "APPARMOR_PROFILE": "/usr/share/apparmor/profiles/mediaharbor" 104 | } 105 | } 106 | }, 107 | "devDependencies": { 108 | "@unocss/preset-icons": "^0.63.4", 109 | "electron": "^33.2.0", 110 | "electron-builder": "^25.1.8" 111 | }, 112 | "dependencies": { 113 | "@iarna/toml": "^2.2.5", 114 | "axios": "^1.7.7", 115 | "cheerio": "^1.0.0", 116 | "cross-spawn": "^7.0.6", 117 | "decompress": "^4.2.1", 118 | "decompress-targz": "^4.1.1", 119 | "decompress-unzip": "^4.0.1", 120 | "electron-prompt": "^1.7.0", 121 | "electron-prompts": "^0.9.13", 122 | "electron-store": "^10.0.0", 123 | "express": "^4.21.2", 124 | "fix-path": "^4.0.0", 125 | "lzma-native": "^8.0.6", 126 | "node-fetch": "^3.3.2", 127 | "progress": "^2.0.3", 128 | "sqlite3": "^5.1.7", 129 | "strip-ansi": "^7.1.0", 130 | "sudo-prompt": "^9.2.1", 131 | "tar": "^7.4.3", 132 | "tar-fs": "^3.0.8", 133 | "undici": "^7.7.0", 134 | "unzipper": "^0.12.3", 135 | "xml2js": "^0.6.2" 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /resources/appx/LargeTile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/appx/LargeTile.png -------------------------------------------------------------------------------- /resources/appx/SmallTile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/appx/SmallTile.png -------------------------------------------------------------------------------- /resources/appx/SplashScreen.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/appx/SplashScreen.png -------------------------------------------------------------------------------- /resources/appx/Square150x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/appx/Square150x150Logo.png -------------------------------------------------------------------------------- /resources/appx/Square44x44Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/appx/Square44x44Logo.png -------------------------------------------------------------------------------- /resources/appx/StoreLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/appx/StoreLogo.png -------------------------------------------------------------------------------- /resources/appx/Wide310x150Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/appx/Wide310x150Logo.png -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icon.png -------------------------------------------------------------------------------- /resources/icons/1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icons/1024x1024.png -------------------------------------------------------------------------------- /resources/icons/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icons/128x128.png -------------------------------------------------------------------------------- /resources/icons/16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icons/16x16.png -------------------------------------------------------------------------------- /resources/icons/256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icons/256x256.png -------------------------------------------------------------------------------- /resources/icons/32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icons/32x32.png -------------------------------------------------------------------------------- /resources/icons/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icons/512x512.png -------------------------------------------------------------------------------- /resources/icons/64x64.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/resources/icons/64x64.png -------------------------------------------------------------------------------- /resources/mediaharbor.apparmor: -------------------------------------------------------------------------------- 1 | # MediaHarbor AppArmor profile 2 | /opt/MediaHarbor/** rw, 3 | /opt/MediaHarbor/chrome-sandbox rix, 4 | /usr/lib/x86_64-linux-gnu/** rmix, 5 | /{dev,proc,sys}/** r, 6 | network, 7 | capability mknod, 8 | capability sys_admin, 9 | -------------------------------------------------------------------------------- /resources/postinst.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Install AppArmor if not installed 4 | if ! command -v apparmor_status &> /dev/null; then 5 | echo "AppArmor not found, installing..." 6 | apt update && apt install -y apparmor apparmor-utils 7 | fi 8 | 9 | # Ensure MediaHarbor AppArmor profile is in place 10 | APPARMOR_PROFILE_PATH="/usr/share/apparmor/profiles/mediaharbor" 11 | if [[ -f "resources/mediaharbor.apparmor" ]]; then 12 | echo "Copying MediaHarbor AppArmor profile..." 13 | cp resources/mediaharbor.apparmor "$APPARMOR_PROFILE_PATH" 14 | apparmor_parser -r "$APPARMOR_PROFILE_PATH" 15 | fi 16 | 17 | # Set permissions for chrome-sandbox 18 | SANDBOX_PATH="/opt/MediaHarbor/chrome-sandbox" 19 | if [[ -f "$SANDBOX_PATH" ]]; then 20 | echo "Setting permissions for chrome-sandbox..." 21 | chown root:root "$SANDBOX_PATH" 22 | chmod 4755 "$SANDBOX_PATH" 23 | else 24 | echo "Warning: $SANDBOX_PATH not found, please ensure MediaHarbor is correctly installed." 25 | fi 26 | 27 | # Reload AppArmor profiles 28 | echo "Reloading AppArmor profiles..." 29 | service apparmor reload 30 | 31 | echo "Setup complete. You should now be able to start MediaHarbor without sandbox issues." 32 | -------------------------------------------------------------------------------- /src/assets/HiRes.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/src/assets/HiRes.jpg -------------------------------------------------------------------------------- /src/assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/src/assets/placeholder.png -------------------------------------------------------------------------------- /src/firststart.css: -------------------------------------------------------------------------------- 1 | *, *::before, *::after { 2 | box-sizing: border-box; 3 | } 4 | 5 | 6 | :root { 7 | --font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 8 | --color-bg: #ffffff; 9 | --color-bg-alt: #f9f9f9; 10 | --color-text: #333333; 11 | --color-primary: #3498db; 12 | --color-primary-dark: #2980b9; 13 | --color-secondary: #2ecc71; 14 | --color-secondary-dark: #27ae60; 15 | --color-muted: #95a5a6; 16 | --color-error: #e74c3c; 17 | --border-radius: 6px; 18 | --transition-speed: 0.3s; 19 | --transition-ease: ease-in-out; 20 | } 21 | 22 | 23 | body { 24 | margin: 0; 25 | padding: 0; 26 | font-family: var(--font-family); 27 | background-color: var(--color-bg-alt); 28 | color: var(--color-text); 29 | line-height: 1.6; 30 | } 31 | 32 | .container { 33 | max-width: 800px; 34 | margin: 0 auto; 35 | padding: 1rem; 36 | position: relative; 37 | min-height: 100vh; 38 | box-sizing: border-box; 39 | } 40 | 41 | 42 | .page { 43 | background-color: var(--color-bg); 44 | border-radius: var(--border-radius); 45 | padding: 2rem; 46 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 47 | position: absolute; 48 | top: 0; 49 | left: 0; 50 | width: 100%; 51 | height: 100%; 52 | box-sizing: border-box; 53 | opacity: 0; 54 | transform: translateX(20px); 55 | transition: opacity var(--transition-speed) var(--transition-ease), transform var(--transition-speed) var(--transition-ease); 56 | display: none; 57 | overflow-y: auto; 58 | } 59 | 60 | .page.active { 61 | display: block; 62 | opacity: 1; 63 | transform: translateX(0); 64 | } 65 | 66 | 67 | h1 { 68 | text-align: center; 69 | color: var(--color-primary); 70 | margin-bottom: 1.5rem; 71 | font-size: 2rem; 72 | } 73 | 74 | .dependencies-list, .dependency-group { 75 | margin-bottom: 1.5rem; 76 | } 77 | 78 | .dependency-group h2 { 79 | color: var(--color-text); 80 | border-bottom: 1px solid #ddd; 81 | padding-bottom: 0.5rem; 82 | margin-bottom: 1rem; 83 | font-size: 1.25rem; 84 | } 85 | 86 | .dependency-group.required .dependency-item:not(.success) { 87 | border-left: 4px solid #e74c3c; 88 | padding-left: 10px; 89 | background-color: rgba(231, 76, 60, 0.05); 90 | } 91 | 92 | .dependency-group.required h2:after { 93 | content: " ★"; 94 | color: #e74c3c; 95 | } 96 | 97 | .dependency-item { 98 | display: flex; 99 | align-items: center; 100 | padding: 0.75rem 1rem; 101 | background-color: var(--color-bg-alt); 102 | border-radius: var(--border-radius); 103 | margin-bottom: 0.75rem; 104 | transition: background-color var(--transition-speed); 105 | } 106 | 107 | .dependency-item:hover { 108 | background-color: #f1f1f1; 109 | } 110 | 111 | .status-icon { 112 | font-size: 1.2rem; 113 | margin-right: 0.75rem; 114 | color: var(--color-primary); 115 | } 116 | 117 | .dep-name { 118 | flex-grow: 1; 119 | font-size: 1rem; 120 | } 121 | 122 | .install-btn { 123 | padding: 0.5rem 1rem; 124 | border: none; 125 | border-radius: var(--border-radius); 126 | background-color: var(--color-primary); 127 | color: #fff; 128 | cursor: pointer; 129 | transition: background-color var(--transition-speed); 130 | } 131 | 132 | .install-btn:hover:not(:disabled) { 133 | background-color: var(--color-primary-dark); 134 | } 135 | 136 | .install-btn:disabled { 137 | background-color: var(--color-muted); 138 | cursor: not-allowed; 139 | } 140 | 141 | .nav-buttons { 142 | display: flex; 143 | justify-content: space-between; 144 | margin-top: 2rem; 145 | } 146 | 147 | .next-btn, .prev-btn, .finish-btn { 148 | padding: 0.75rem 1.5rem; 149 | border: none; 150 | border-radius: var(--border-radius); 151 | cursor: pointer; 152 | font-size: 1rem; 153 | transition: background-color var(--transition-speed); 154 | } 155 | 156 | .next-btn, .finish-btn { 157 | background-color: var(--color-secondary); 158 | color: #fff; 159 | } 160 | 161 | .next-btn:hover, .finish-btn:hover { 162 | background-color: var(--color-secondary-dark); 163 | } 164 | 165 | .next-btn:disabled { 166 | background-color: #cccccc; 167 | cursor: not-allowed; 168 | opacity: 0.7; 169 | } 170 | 171 | .next-btn:disabled:hover { 172 | background-color: #cccccc; 173 | box-shadow: none; 174 | transform: none; 175 | } 176 | 177 | .prev-btn { 178 | background-color: var(--color-muted); 179 | color: #fff; 180 | } 181 | 182 | .prev-btn:hover { 183 | background-color: #7f8c8d; 184 | } 185 | 186 | #progress-overlay { 187 | position: fixed; 188 | top: 0; 189 | left: 0; 190 | width: 100%; 191 | height: 100%; 192 | background: rgba(0, 0, 0, 0.6); 193 | display: flex; 194 | justify-content: center; 195 | align-items: center; 196 | z-index: 1000; 197 | opacity: 0; 198 | visibility: hidden; 199 | transition: opacity var(--transition-speed) var(--transition-ease), visibility var(--transition-speed) var(--transition-ease); 200 | } 201 | 202 | #progress-overlay.active { 203 | opacity: 1; 204 | visibility: visible; 205 | } 206 | 207 | .progress-container { 208 | background-color: var(--color-bg); 209 | padding: 2rem; 210 | border-radius: var(--border-radius); 211 | text-align: center; 212 | width: 90%; 213 | max-width: 350px; 214 | box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); 215 | } 216 | 217 | .progress-bar { 218 | height: 10px; 219 | background-color: #ddd; 220 | border-radius: 5px; 221 | overflow: hidden; 222 | margin: 1rem 0; 223 | } 224 | 225 | .progress-fill { 226 | height: 100%; 227 | background-color: var(--color-primary); 228 | width: 0%; 229 | transition: width var(--transition-speed) ease; 230 | } 231 | 232 | .success .status-icon { 233 | color: var(--color-secondary); 234 | } 235 | 236 | .error .status-icon { 237 | color: var(--color-error); 238 | } 239 | 240 | .warning { 241 | color: darkorange; 242 | justify-self: center; 243 | } 244 | 245 | .warning-div { 246 | background-color: #f8f9fa; 247 | border-radius: 8px; 248 | padding: 15px; 249 | margin-top: 20px; 250 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05); 251 | display: flex; 252 | align-items: center; 253 | justify-content: space-between; 254 | flex-wrap: wrap; 255 | } 256 | 257 | .warning-div .warning { 258 | margin: 0; 259 | font-size: 14px; 260 | color: #555; 261 | flex: 1; 262 | min-width: 250px; 263 | } 264 | 265 | 266 | .progress-overlay { 267 | display: none; 268 | position: fixed; 269 | top: 0; 270 | left: 0; 271 | width: 100%; 272 | height: 100%; 273 | background-color: rgba(0, 0, 0, 0.7); 274 | z-index: 1000; 275 | justify-content: center; 276 | align-items: center; 277 | } 278 | 279 | .progress-overlay.active { 280 | display: flex; 281 | } 282 | 283 | .progress-container { 284 | background-color: white; 285 | padding: 20px; 286 | border-radius: 8px; 287 | width: 80%; 288 | max-width: 400px; 289 | } 290 | 291 | .progress-bar { 292 | width: 100%; 293 | height: 20px; 294 | background-color: #f0f0f0; 295 | border-radius: 10px; 296 | overflow: hidden; 297 | margin-bottom: 10px; 298 | } 299 | 300 | .progress-fill { 301 | width: 0%; 302 | height: 100%; 303 | background-color: #4CAF50; 304 | transition: width 0.3s ease-in-out; 305 | } 306 | 307 | .progress-status { 308 | text-align: center; 309 | color: #333; 310 | font-size: 14px; 311 | } 312 | 313 | /* Modal Styles */ 314 | .modal { 315 | display: none; 316 | position: fixed; 317 | z-index: 1000; 318 | left: 0; 319 | top: 0; 320 | width: 100%; 321 | height: 100%; 322 | overflow: auto; 323 | background-color: rgba(0, 0, 0, 0.6); 324 | } 325 | 326 | .modal-content { 327 | background-color: #fff; 328 | margin: 3% auto; 329 | padding: 25px; 330 | border-radius: 10px; 331 | box-shadow: 0 5px 20px rgba(0, 0, 0, 0.3); 332 | width: 85%; 333 | max-width: 950px; 334 | max-height: 90vh; 335 | overflow-y: auto; 336 | animation: modalFadeIn 0.3s ease; 337 | } 338 | 339 | @keyframes modalFadeIn { 340 | from { 341 | opacity: 0; 342 | transform: translateY(-20px); 343 | } 344 | to { 345 | opacity: 1; 346 | transform: translateY(0); 347 | } 348 | } 349 | 350 | .close-modal-btn { 351 | padding: 12px 25px; 352 | background-color: #3498db; 353 | color: white; 354 | border: none; 355 | border-radius: 5px; 356 | cursor: pointer; 357 | font-size: 16px; 358 | font-weight: 500; 359 | transition: all 0.3s ease; 360 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 361 | } 362 | 363 | .close-modal-btn:hover { 364 | background-color: #2980b9; 365 | transform: translateY(-2px); 366 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); 367 | } 368 | 369 | .close-modal:hover, 370 | .close-modal:focus { 371 | color: black; 372 | text-decoration: none; 373 | } 374 | 375 | .modal-intro { 376 | margin-bottom: 20px; 377 | font-size: 16px; 378 | } 379 | 380 | .modal-section { 381 | margin-bottom: 30px; 382 | } 383 | 384 | .modal-section h2 { 385 | margin-bottom: 15px; 386 | padding-bottom: 5px; 387 | border-bottom: 1px solid #eee; 388 | } 389 | 390 | .installation-table { 391 | display: table; 392 | width: 100%; 393 | border-collapse: collapse; 394 | margin-bottom: 20px; 395 | } 396 | 397 | .table-row { 398 | display: table-row; 399 | } 400 | 401 | .table-row.header { 402 | background-color: #f5f5f5; 403 | font-weight: bold; 404 | } 405 | 406 | .table-cell { 407 | display: table-cell; 408 | padding: 10px; 409 | border: 1px solid #ddd; 410 | vertical-align: middle; 411 | } 412 | 413 | .table-row:nth-child(even) { 414 | background-color: #f9f9f9; 415 | } 416 | 417 | .table-row:hover { 418 | background-color: #f1f1f1; 419 | } 420 | 421 | .table-cell code { 422 | background-color: #f7f7f7; 423 | padding: 3px 6px; 424 | border-radius: 3px; 425 | font-family: monospace; 426 | } 427 | 428 | .modal-buttons { 429 | text-align: center; 430 | margin-top: 20px; 431 | } 432 | 433 | .close-modal-btn { 434 | padding: 10px 20px; 435 | background-color: #4CAF50; 436 | color: white; 437 | border: none; 438 | border-radius: 4px; 439 | cursor: pointer; 440 | font-size: 16px; 441 | } 442 | 443 | .close-modal-btn:hover { 444 | background-color: #45a049; 445 | } 446 | 447 | .manual-install-btn { 448 | background-color: #3498db; 449 | color: white; 450 | border: none; 451 | padding: 8px 15px; 452 | border-radius: 5px; 453 | cursor: pointer; 454 | font-size: 14px; 455 | margin-left: 10px; 456 | transition: all 0.3s ease; 457 | box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); 458 | display: inline-flex; 459 | align-items: center; 460 | font-weight: 500; 461 | } 462 | 463 | .manual-install-btn:hover { 464 | background-color: #2980b9; 465 | transform: translateY(-2px); 466 | box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); 467 | } 468 | 469 | .manual-install-btn:active { 470 | transform: translateY(0); 471 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); 472 | } 473 | 474 | .manual-install-btn:before { 475 | content: "📄"; 476 | margin-right: 8px; 477 | font-size: 16px; 478 | } 479 | -------------------------------------------------------------------------------- /src/funcs/apis/applemusicapi.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | from pyapplemusicapi import search_album, search_artist, search_track 4 | 5 | def search_apple_music(term, media_type): 6 | try: 7 | if media_type == "track": 8 | results = search_track(term) 9 | elif media_type == "album": 10 | results = search_album(term) 11 | elif media_type == "artist": 12 | results = search_artist(term) 13 | else: 14 | print(json.dumps({"error": f"Media type '{media_type}' is not supported."}, indent=4)) 15 | return 16 | 17 | # Create a list to hold all results 18 | all_results = [] 19 | 20 | for item in results: 21 | json_data = getattr(item, 'json', {}) 22 | if json_data: # Only add non-empty results 23 | all_results.append(json_data) 24 | 25 | # Print all results as a single JSON array 26 | print(json.dumps(all_results, indent=4)) 27 | 28 | except json.JSONDecodeError as e: 29 | print(json.dumps({ 30 | "error": "JSON parsing error", 31 | "details": str(e) 32 | }, indent=4)) 33 | except Exception as e: 34 | print(json.dumps({ 35 | "error": "An error occurred", 36 | "details": str(e) 37 | }, indent=4)) 38 | 39 | def main(): 40 | parser = argparse.ArgumentParser(description="Search Apple Music for songs, albums, and artists.") 41 | parser.add_argument("term", type=str, help="The search term (e.g., song name, artist name)") 42 | parser.add_argument( 43 | "--media_type", 44 | type=str, 45 | choices=["track", "album", "artist"], 46 | default="track", 47 | help="Type of media to search for (default: track)" 48 | ) 49 | 50 | args = parser.parse_args() 51 | search_apple_music(args.term, args.media_type) 52 | 53 | if __name__ == "__main__": 54 | main() -------------------------------------------------------------------------------- /src/funcs/apis/deezerapi.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import argparse 3 | import json 4 | import ssl 5 | 6 | # To ensure SSL verification (security best practice) 7 | ssl._create_default_https_context = ssl._create_unverified_context 8 | 9 | # Function to get track details 10 | def get_track(track_id): 11 | track_url = f'https://api.deezer.com/track/{track_id}' 12 | response = requests.get(track_url) 13 | return response.json() 14 | 15 | # Function to search tracks 16 | def search_tracks(query): 17 | search_url = f'https://api.deezer.com/search/track?q={query}' 18 | response = requests.get(search_url) 19 | return response.json()['data'] 20 | 21 | # Function to search albums 22 | def search_albums(query): 23 | search_url = f'https://api.deezer.com/search/album?q={query}' 24 | response = requests.get(search_url) 25 | return response.json()['data'] 26 | 27 | # Function to search artists 28 | def search_artists(query): 29 | search_url = f'https://api.deezer.com/search/artist?q={query}' 30 | response = requests.get(search_url) 31 | return response.json()['data'] 32 | 33 | # Function to search playlists 34 | def search_playlists(query): 35 | search_url = f'https://api.deezer.com/search/playlist?q={query}' 36 | response = requests.get(search_url) 37 | return response.json()['data'] 38 | 39 | # Function to get track list from an album or playlist by ID 40 | def get_track_list(item): 41 | if '/' not in item: 42 | raise ValueError("Invalid format. Use 'album/ID' or 'playlist/ID'.") 43 | 44 | item_type, item_id = item.split('/') 45 | if item_type not in ['album', 'playlist']: 46 | raise ValueError("Invalid item type. Must be 'album' or 'playlist'.") 47 | 48 | # Fetch item details (album/playlist metadata) 49 | details_url = f'https://api.deezer.com/{item_type}/{item_id}' 50 | details_response = requests.get(details_url) 51 | if details_response.status_code != 200: 52 | return {'error': f"Failed to fetch details for {item_type} ID {item_id}"} 53 | 54 | item_details = details_response.json() 55 | 56 | # Fetch track list 57 | track_url = f'https://api.deezer.com/{item_type}/{item_id}/tracks' 58 | track_response = requests.get(track_url) 59 | if track_response.status_code != 200: 60 | return {'error': f"Failed to fetch tracks for {item_type} ID {item_id}"} 61 | 62 | track_list = track_response.json()['data'] 63 | 64 | # Include album/playlist metadata 65 | metadata = { 66 | "type": item_type, 67 | "id": item_id, 68 | "name": item_details.get("title", "Unknown Title"), 69 | "artist": item_details.get("artist", {}).get("name", "Unknown Artist") if item_type == "album" else "N/A", 70 | "release_date": item_details.get("release_date", "Unknown Date"), 71 | "total_tracks": len(track_list) 72 | } 73 | metadata['tracks'] = track_list 74 | 75 | return metadata 76 | 77 | if __name__ == '__main__': 78 | parser = argparse.ArgumentParser(description='Deezer API Script') 79 | parser.add_argument('--get-details', type=int, help='Get details of a track by ID') 80 | parser.add_argument('--search-track', type=str, help='Search for tracks') 81 | parser.add_argument('--search-album', type=str, help='Search for albums') 82 | parser.add_argument('--search-artist', type=str, help='Search for artists') 83 | parser.add_argument('--search-playlist', type=str, help='Search for playlists') 84 | parser.add_argument('--get-track-list', type=str, 85 | help="Get track list from an album or playlist by ID. Format: 'album/ID' or 'playlist/ID'.") 86 | 87 | args = parser.parse_args() 88 | 89 | if args.get_details: 90 | track_details = get_track(args.get_details) 91 | print(json.dumps(track_details, indent=4)) # Print as formatted JSON 92 | 93 | if args.search_track: 94 | found_tracks = search_tracks(args.search_track) 95 | print(json.dumps(found_tracks, indent=4)) # Print as formatted JSON 96 | 97 | if args.search_album: 98 | found_albums = search_albums(args.search_album) 99 | print(json.dumps(found_albums, indent=4)) # Print as formatted JSON 100 | 101 | if args.search_artist: 102 | found_artists = search_artists(args.search_artist) 103 | print(json.dumps(found_artists, indent=4)) # Print as formatted JSON 104 | 105 | if args.search_playlist: 106 | found_playlists = search_playlists(args.search_playlist) 107 | print(json.dumps(found_playlists, indent=4)) # Print as formatted JSON 108 | 109 | if args.get_track_list: 110 | track_list = get_track_list(args.get_track_list) 111 | print(json.dumps(track_list, indent=4)) # Print as formatted JSON 112 | -------------------------------------------------------------------------------- /src/funcs/apis/qobuzapi.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import argparse 3 | import json 4 | 5 | # Set your Qobuz credentials 6 | app_id = "950096963" 7 | app_secret = "10b251c286cfbf64d6b7105f253d9a2e" 8 | auth_token = "u6lHtzb1Vv_TbNYYL_PrIzVZfkMpxUJ4Y4AkpdrfFRaj5o1sbLP7ENCKVD-wQEmkMbQIN-G6vcgzPvwaZdEvPA" 9 | 10 | # Define a function to search and return data 11 | def search_qobuz(query, search_type): 12 | try: 13 | url = f"https://www.qobuz.com/api.json/0.2/{search_type}/search?app_id={app_id}&query={query}&limit=10" 14 | response = requests.get(url, headers={"X-User-Auth-Token": auth_token}) 15 | return response.json() 16 | except Exception as e: 17 | print(f"An error occurred: {e}") 18 | return {} 19 | 20 | # Define a function to get track details 21 | def get_track_details(track_id): 22 | try: 23 | url = f"https://www.qobuz.com/api.json/0.2/track/get?app_id={app_id}&track_id={track_id}" 24 | response = requests.get(url, headers={"X-User-Auth-Token": auth_token}) 25 | return response.json() 26 | except Exception as e: 27 | print(f"An error occurred: {e}") 28 | return {} 29 | 30 | # Define a function to get the track stream 31 | def get_track_stream(track_id, format_id=27): 32 | try: 33 | url = f"https://www.qobuz.com/api.json/0.2/track/getFileUrl?app_id={app_id}&track_id={track_id}&format_id={format_id}" 34 | response = requests.get(url, headers={"X-User-Auth-Token": auth_token}) 35 | return response.json() 36 | except Exception as e: 37 | print(f"An error occurred: {e}") 38 | return {} 39 | 40 | # Define a function to get album list for an artist by artist ID 41 | def get_album_list(artist_id): 42 | try: 43 | url = f"https://www.qobuz.com/api.json/0.2/artist/get?app_id={app_id}&artist_id={artist_id}" 44 | response = requests.get(url, headers={"X-User-Auth-Token": auth_token}) 45 | artist_data = response.json() 46 | 47 | if "albums" in artist_data: 48 | return artist_data["albums"] 49 | else: 50 | return {"status": "error", "message": "No albums found for this artist."} 51 | except Exception as e: 52 | print(f"An error occurred: {e}") 53 | return {} 54 | 55 | def get_track_list(entity_id, entity_type): 56 | try: 57 | if entity_type == "album": 58 | url = f"https://www.qobuz.com/api.json/0.2/album/get?app_id={app_id}&album_id={entity_id}" 59 | elif entity_type == "playlist": 60 | url = f"https://www.qobuz.com/api.json/0.2/playlist/get?app_id={app_id}&playlist_id={entity_id}&extra=tracks" 61 | elif entity_type == "artist": 62 | url = f"https://www.qobuz.com/api.json/0.2/artist/get?app_id={app_id}&artist_id={entity_id}&extra=albums" 63 | else: 64 | return {"status": "error", "message": f"Unknown entity type: {entity_type}"} 65 | 66 | response = requests.get(url, headers={"X-User-Auth-Token": auth_token}) 67 | entity_data = response.json() 68 | 69 | # Handle playlist-specific track retrieval 70 | if entity_type == "playlist" and "tracks" in entity_data: 71 | return entity_data["tracks"]["items"] # Tracks are nested under 'tracks' > 'items' 72 | 73 | # Handle album-specific track retrieval 74 | if entity_type == "album" and "tracks" in entity_data: 75 | return entity_data["tracks"]["items"] 76 | 77 | # Handle artist data retrieval 78 | if entity_type == "artist": 79 | return entity_data 80 | 81 | return {"status": "error", "message": f"No tracks found for this {entity_type}."} 82 | except Exception as e: 83 | print(f"An error occurred: {e}") 84 | return {} 85 | 86 | if __name__ == "__main__": 87 | parser = argparse.ArgumentParser(description='Search Qobuz or get details.') 88 | parser.add_argument('--search-track', help='Search for a track by release name') 89 | parser.add_argument('--search-artist', help='Search for an artist') 90 | parser.add_argument('--search-album', help='Search for an album by label') 91 | parser.add_argument('--search-playlist', help='Search for a playlist by query') 92 | parser.add_argument('--get-details', help='Get track details by track ID') 93 | parser.add_argument('--get-stream', help='Get track stream by track ID') 94 | parser.add_argument('--format-id', type=int, default=27, help='Audio format ID for track stream (default: 27)') 95 | parser.add_argument('--get-album-list', help='Get album list by artist ID') 96 | parser.add_argument('--get-track-list', help='Get track list by entity_type/entity_id (e.g., album/123, playlist/456, artist/789)') 97 | args = parser.parse_args() 98 | 99 | if args.search_track: 100 | results = search_qobuz(args.search_track, "track") 101 | print(json.dumps(results, indent=4)) 102 | elif args.search_artist: 103 | results = search_qobuz(args.search_artist, "artist") 104 | print(json.dumps(results, indent=4)) 105 | elif args.search_album: 106 | results = search_qobuz(args.search_album, "album") 107 | print(json.dumps(results, indent=4)) 108 | elif args.search_playlist: 109 | results = search_qobuz(args.search_playlist, "playlist") 110 | print(json.dumps(results, indent=4)) 111 | elif args.get_details: 112 | details = get_track_details(args.get_details) 113 | print(json.dumps(details, indent=4)) 114 | elif args.get_stream: 115 | stream = get_track_stream(args.get_stream, format_id=args.format_id) 116 | print(json.dumps(stream, indent=4)) 117 | elif args.get_album_list: 118 | albums = get_album_list(args.get_album_list) 119 | print(json.dumps(albums, indent=4)) 120 | elif args.get_track_list: 121 | entity_type, entity_id = args.get_track_list.split("/") 122 | tracks = get_track_list(entity_id, entity_type) 123 | print(json.dumps(tracks, indent=4)) 124 | else: 125 | print("Please provide a valid argument.") -------------------------------------------------------------------------------- /src/funcs/apis/spotifyapi.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import datetime 3 | import json 4 | import argparse 5 | import requests 6 | import sys 7 | import os 8 | from base64 import b64encode 9 | from urllib.parse import quote 10 | 11 | class SpotifyAPI: 12 | API_URL = "https://api.spotify.com/v1" 13 | TOKEN_URL = "https://accounts.spotify.com/api/token" 14 | 15 | def __init__(self): 16 | self.session = requests.Session() 17 | self.access_token = None 18 | self.token_expiry = None 19 | self.load_credentials() 20 | self.authenticate() 21 | 22 | def load_credentials(self): 23 | try: 24 | if os.path.exists('apis.json'): 25 | config_path = 'apis.json' 26 | else: 27 | script_dir = os.path.dirname(os.path.abspath(__file__)) 28 | config_path = os.path.join(script_dir, 'apis.json') 29 | if not os.path.exists(config_path): 30 | raise FileNotFoundError("apis.json not found") 31 | 32 | with open(config_path, 'r') as f: 33 | credentials = json.load(f) 34 | 35 | if 'SPOTIFY_CLIENT_ID' not in credentials or 'SPOTIFY_CLIENT_SECRET' not in credentials: 36 | raise KeyError("SPOTIFY_CLIENT_ID or SPOTIFY_CLIENT_SECRET not found in apis.json") 37 | 38 | self.client_id = credentials['SPOTIFY_CLIENT_ID'] 39 | self.client_secret = credentials['SPOTIFY_CLIENT_SECRET'] 40 | 41 | except Exception as e: 42 | print(json.dumps({'error': f"Failed to load credentials: {str(e)}"}, indent=2)) 43 | sys.exit(1) 44 | 45 | def authenticate(self): 46 | if self.access_token and self.token_expiry and datetime.datetime.now() < self.token_expiry: 47 | return 48 | auth_header = b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode() 49 | headers = { 50 | "Authorization": f"Basic {auth_header}", 51 | "Content-Type": "application/x-www-form-urlencoded" 52 | } 53 | data = {"grant_type": "client_credentials"} 54 | 55 | response = self.session.post(self.TOKEN_URL, headers=headers, data=data) 56 | response.raise_for_status() 57 | 58 | token_data = response.json() 59 | self.access_token = token_data['access_token'] 60 | self.token_expiry = datetime.datetime.now() + datetime.timedelta(seconds=token_data['expires_in']) 61 | 62 | def make_request(self, method, url, params=None): 63 | self.authenticate() 64 | headers = { 65 | "Authorization": f"Bearer {self.access_token}", 66 | "Accept": "application/json" 67 | } 68 | response = self.session.request(method, url, headers=headers, params=params) 69 | response.raise_for_status() 70 | return response.json() 71 | 72 | def get_track(self, track_id: str): 73 | """ 74 | Get details of a specific track by its ID. 75 | 76 | Args: 77 | track_id (str): The Spotify ID of the track 78 | 79 | Returns: 80 | dict: Dictionary containing track title, album art, and artist name 81 | """ 82 | track_data = self.make_request("GET", f"{self.API_URL}/tracks/{track_id}") 83 | 84 | track_info = { 85 | "title": track_data["name"], 86 | "album_art": track_data["album"]["images"][0]["url"] if track_data["album"].get("images") else None, 87 | "artist_name": track_data["artists"][0]["name"] 88 | } 89 | 90 | return track_info 91 | def get_album_info(self, album_id: str): 92 | """ 93 | Get essential details of a specific album by its ID. 94 | 95 | Args: 96 | album_id (str): The Spotify ID of the album 97 | 98 | Returns: 99 | dict: Dictionary containing album name, release date, artist name, and cover URL 100 | """ 101 | album_data = self.make_request("GET", f"{self.API_URL}/albums/{album_id}") 102 | 103 | album_info = { 104 | "album_name": album_data["name"], 105 | "release_date": album_data["release_date"], 106 | "artist_name": album_data["artists"][0]["name"], 107 | "cover_url": album_data["images"][0]["url"] if album_data.get("images") else None 108 | } 109 | 110 | return album_info 111 | 112 | def get_playlist_info(self, playlist_id: str): 113 | """ 114 | Get essential details of a specific playlist by its ID. 115 | 116 | Args: 117 | playlist_id (str): The Spotify ID of the playlist 118 | 119 | Returns: 120 | dict: Dictionary containing playlist name, owner name, description, and cover URL 121 | """ 122 | playlist_data = self.make_request("GET", f"{self.API_URL}/playlists/{playlist_id}") 123 | 124 | playlist_info = { 125 | "playlist_name": playlist_data["name"], 126 | "owner_name": playlist_data["owner"]["display_name"], 127 | "description": playlist_data.get("description", ""), 128 | "cover_url": playlist_data["images"][0]["url"] if playlist_data.get("images") else None, 129 | "total_tracks": playlist_data["tracks"]["total"] 130 | } 131 | 132 | return playlist_info 133 | def get_album_tracks(self, album_id: str): 134 | album_data = self.get_album(album_id) 135 | tracks_data = self.make_request("GET", f"{self.API_URL}/albums/{album_id}/tracks") 136 | 137 | album_info = { 138 | "album_name": album_data["name"], 139 | "release_date": album_data["release_date"], 140 | "artist_name": album_data["artists"][0]["name"], 141 | "cover_url": album_data["images"][0]["url"] if album_data.get("images") else None, 142 | "tracks": tracks_data["items"] 143 | } 144 | 145 | return album_info 146 | 147 | def get_playlist_tracks(self, playlist_id: str): 148 | playlist_data = self.make_request("GET", f"{self.API_URL}/playlists/{playlist_id}") 149 | tracks_data = self.make_request("GET", f"{self.API_URL}/playlists/{playlist_id}/tracks") 150 | 151 | playlist_info = { 152 | "playlist_name": playlist_data["name"], 153 | "owner_name": playlist_data["owner"]["display_name"], 154 | "cover_url": playlist_data["images"][0]["url"] if playlist_data.get("images") else None, 155 | "tracks": tracks_data["items"] 156 | } 157 | 158 | return playlist_info 159 | 160 | def get_album(self, album_id: str): 161 | return self.make_request("GET", f"{self.API_URL}/albums/{album_id}") 162 | 163 | def search_tracks(self, query: str, limit: int = 10): 164 | params = { 165 | "q": query, 166 | "type": "track", 167 | "limit": limit 168 | } 169 | return self.make_request("GET", f"{self.API_URL}/search", params=params) 170 | 171 | def search_albums(self, query: str, limit: int = 10): 172 | params = { 173 | "q": query, 174 | "type": "album", 175 | "limit": limit 176 | } 177 | return self.make_request("GET", f"{self.API_URL}/search", params=params) 178 | 179 | def search_playlists(self, query: str, limit: int = 10): 180 | params = { 181 | "q": query, 182 | "type": "playlist", 183 | "limit": limit 184 | } 185 | return self.make_request("GET", f"{self.API_URL}/search", params=params) 186 | 187 | def search_episodes(self, query: str, limit: int = 10): 188 | params = { 189 | "q": query, 190 | "type": "episode", 191 | "limit": limit, 192 | "market": "US" 193 | } 194 | return self.make_request("GET", f"{self.API_URL}/search", params=params) 195 | 196 | def search_artists(self, query: str, limit: int = 10): 197 | params = { 198 | "q": query, 199 | "type": "artist", 200 | "limit": limit 201 | } 202 | return self.make_request("GET", f"{self.API_URL}/search", params=params) 203 | 204 | def search_podcasts(self, query: str, limit: int = 10): 205 | params = { 206 | "q": query, 207 | "type": "show", 208 | "limit": limit, 209 | "market": "US" 210 | } 211 | return self.make_request("GET", f"{self.API_URL}/search", params=params) 212 | 213 | def parse_resource_id(resource_string: str): 214 | try: 215 | resource_type, resource_id = resource_string.split('/') 216 | if resource_type not in ['album', 'playlist']: 217 | raise ValueError 218 | return resource_type, resource_id 219 | except ValueError: 220 | raise ValueError("Format must be 'album/ID' or 'playlist/ID'") 221 | 222 | def main(): 223 | parser = argparse.ArgumentParser(description="Spotify API script") 224 | parser.add_argument('--search-track', help='Track name to search for') 225 | parser.add_argument('--search-album', help='Album name to search for') 226 | parser.add_argument('--search-playlist', help='Playlist name to search for') 227 | parser.add_argument('--search-episode', help='Episode name to search for') 228 | parser.add_argument('--search-artist', help='Artist name to search for') 229 | parser.add_argument('--search-podcast', help='Podcast name to search for') 230 | parser.add_argument('--get-track-list', help='Get tracks from album or playlist (format: album/ID or playlist/ID)') 231 | parser.add_argument('--get-track', help='Get track details by ID') 232 | parser.add_argument('--get-album-info', help='Get album details by ID') 233 | parser.add_argument('--get-playlist-info', help='Get playlist details by ID') 234 | args = parser.parse_args() 235 | 236 | try: 237 | spotify_api = SpotifyAPI() 238 | 239 | if args.get_track_list: 240 | resource_type, resource_id = parse_resource_id(args.get_track_list) 241 | if resource_type == 'album': 242 | results = spotify_api.get_album_tracks(resource_id) 243 | else: # playlist 244 | results = spotify_api.get_playlist_tracks(resource_id) 245 | print(json.dumps(results, indent=2)) 246 | elif args.search_track: 247 | results = spotify_api.search_tracks(query=args.search_track) 248 | print(json.dumps(results, indent=2)) 249 | elif args.search_album: 250 | results = spotify_api.search_albums(query=args.search_album) 251 | print(json.dumps(results, indent=2)) 252 | elif args.search_playlist: 253 | results = spotify_api.search_playlists(query=args.search_playlist) 254 | print(json.dumps(results, indent=2)) 255 | elif args.search_episode: 256 | results = spotify_api.search_episodes(query=args.search_episode) 257 | print(json.dumps(results, indent=2)) 258 | elif args.search_artist: 259 | results = spotify_api.search_artists(query=args.search_artist) 260 | print(json.dumps(results, indent=2)) 261 | elif args.search_podcast: 262 | results = spotify_api.search_podcasts(query=args.search_podcast) 263 | print(json.dumps(results, indent=2)) 264 | elif args.get_track: 265 | results = spotify_api.get_track(args.get_track) 266 | print(json.dumps(results, indent=2)) 267 | elif args.get_album_info: 268 | results = spotify_api.get_album_info(args.get_album_info) 269 | print(json.dumps(results, indent=2)) 270 | elif args.get_playlist_info: 271 | results = spotify_api.get_playlist_info(args.get_playlist_info) 272 | print(json.dumps(results, indent=2)) 273 | except Exception as e: 274 | print(json.dumps({'error': str(e)}, indent=2)) 275 | sys.exit(1) 276 | 277 | if __name__ == "__main__": 278 | main() 279 | -------------------------------------------------------------------------------- /src/funcs/apis/tidalapi.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import json 3 | import argparse 4 | import requests 5 | import os 6 | from base64 import b64encode 7 | import sys 8 | class TidalAPI: 9 | API_URL = "https://openapi.tidal.com/v2" 10 | TOKEN_URL = "https://auth.tidal.com/v1/oauth2/token" 11 | STREAM_API_URL = "https://api.tidal.com/v1" # Different base URL for stream endpoint 12 | 13 | def __init__(self): 14 | self.session = requests.Session() 15 | self.access_token = None 16 | self.token_expiry = None 17 | self.load_credentials() 18 | self.authenticate() 19 | 20 | def load_credentials(self): 21 | try: 22 | # First check if apis.json exists in the current directory 23 | if os.path.exists('apis.json'): 24 | config_path = 'apis.json' 25 | else: 26 | # Try to find it in the script's directory 27 | script_dir = os.path.dirname(os.path.abspath(__file__)) 28 | config_path = os.path.join(script_dir, 'apis.json') 29 | 30 | if not os.path.exists(config_path): 31 | raise FileNotFoundError("apis.json not found") 32 | 33 | with open(config_path, 'r') as f: 34 | credentials = json.load(f) 35 | 36 | if 'TIDAL_CLIENT_ID' not in credentials or 'TIDAL_CLIENT_SECRET' not in credentials: 37 | raise KeyError("TIDAL_CLIENT_ID or TIDAL_CLIENT_SECRET not found in apis.json") 38 | 39 | self.client_id = credentials['TIDAL_CLIENT_ID'] 40 | self.client_secret = credentials['TIDAL_CLIENT_SECRET'] 41 | 42 | except Exception as e: 43 | print(json.dumps({'error': f"Failed to load credentials: {str(e)}"}, indent=2)) 44 | sys.exit(1) 45 | def authenticate(self): 46 | if self.access_token and self.token_expiry and datetime.datetime.now() < self.token_expiry: 47 | return 48 | auth_header = b64encode(f"{self.client_id}:{self.client_secret}".encode()).decode() 49 | headers = { 50 | "Authorization": f"Basic {auth_header}", 51 | "Content-Type": "application/x-www-form-urlencoded" 52 | } 53 | data = {"grant_type": "client_credentials"} 54 | 55 | response = self.session.post(self.TOKEN_URL, headers=headers, data=data) 56 | response.raise_for_status() 57 | token_data = response.json() 58 | self.access_token = token_data['access_token'] 59 | self.token_expiry = datetime.datetime.now() + datetime.timedelta(seconds=token_data['expires_in']) 60 | 61 | def make_request(self, method, url, params=None, base_url=None): 62 | self.authenticate() 63 | headers = { 64 | "Authorization": f"Bearer {self.access_token}", 65 | "Accept": "application/vnd.api+json" 66 | } 67 | full_url = f"{base_url or self.API_URL}/{url}" 68 | response = self.session.request(method, full_url, headers=headers, params=params) 69 | response.raise_for_status() 70 | return response.json() 71 | 72 | def get_stream_url(self, track_id: str, country_code: str, user_id: str = None, user_token: str = None): 73 | """Get the stream URL for a track using either client credentials or user token.""" 74 | headers = { 75 | "Authorization": f"Bearer {user_token if user_token else self.access_token}" 76 | } 77 | 78 | params = {'countryCode': country_code} 79 | url = f"{self.STREAM_API_URL}/tracks/{track_id}/streamUrl" 80 | 81 | response = self.session.get(url, headers=headers, params=params) 82 | response.raise_for_status() 83 | return response.json() 84 | 85 | def get_track(self, track_id: str, country_code: str): 86 | params = {"countryCode": country_code, "include": "artists,albums"} 87 | return self.make_request("GET", f"tracks/{track_id}", params=params) 88 | 89 | def get_album(self, album_id: str, country_code: str): 90 | # Fetch album details 91 | album_response = self.make_request( 92 | "GET", 93 | f"albums/{album_id}", 94 | params={"countryCode": country_code, "include": "items"} 95 | ) 96 | 97 | # Check if album_response contains artist details or artistId 98 | if album_response and "artist" not in album_response: 99 | # Fetch artist details if needed 100 | artist_id = album_response.get("artistId") 101 | if artist_id: 102 | artist_response = self.make_request( 103 | "GET", 104 | f"artists/{artist_id}", 105 | params={"countryCode": country_code} 106 | ) 107 | # Add artist name to the album response 108 | if artist_response: 109 | album_response["artistName"] = artist_response.get("name", "Unknown Artist") 110 | 111 | return album_response 112 | 113 | 114 | def search_tracks(self, query: str, country_code: str, limit: int = 30): 115 | self.authenticate() 116 | url = "https://openapi.tidal.com/search" 117 | params = { 118 | "query": query, 119 | "type": "TRACKS", 120 | "offset": 0, 121 | "limit": limit, 122 | "countryCode": country_code, 123 | "popularity": "WORLDWIDE" 124 | } 125 | headers = { 126 | "Authorization": f"Bearer {self.access_token}", 127 | "Content-Type": "application/vnd.tidal.v1+json", 128 | "Accept": "application/vnd.tidal.v1+json" 129 | } 130 | 131 | response = self.session.get(url, headers=headers, params=params) 132 | response.raise_for_status() 133 | return response.json() 134 | 135 | def search_albums(self, query: str, country_code: str, limit: int = 30): 136 | self.authenticate() 137 | url = "https://openapi.tidal.com/search" 138 | params = { 139 | "query": query, 140 | "type": "ALBUMS", 141 | "offset": 0, 142 | "limit": limit, 143 | "countryCode": country_code, 144 | "popularity": "WORLDWIDE" 145 | } 146 | headers = { 147 | "Authorization": f"Bearer {self.access_token}", 148 | "Content-Type": "application/vnd.tidal.v1+json", 149 | "Accept": "application/vnd.tidal.v1+json" 150 | } 151 | 152 | response = self.session.get(url, headers=headers, params=params) 153 | response.raise_for_status() 154 | return response.json() 155 | 156 | def search_artists(self, query: str, country_code: str, limit: int = 30): 157 | self.authenticate() 158 | url = "https://openapi.tidal.com/search" 159 | params = { 160 | "query": query, 161 | "type": "ARTISTS", 162 | "offset": 0, 163 | "limit": limit, 164 | "countryCode": country_code, 165 | "popularity": "WORLDWIDE" 166 | } 167 | headers = { 168 | "Authorization": f"Bearer {self.access_token}", 169 | "Content-Type": "application/vnd.tidal.v1+json", 170 | "Accept": "application/vnd.tidal.v1+json" 171 | } 172 | 173 | response = self.session.get(url, headers=headers, params=params) 174 | response.raise_for_status() 175 | return response.json() 176 | 177 | def search_playlists(self, query: str, country_code: str, limit: int = 30): 178 | self.authenticate() 179 | url = "https://openapi.tidal.com/search" 180 | params = { 181 | "query": query, 182 | "type": "PLAYLISTS", 183 | "offset": 0, 184 | "limit": limit, 185 | "countryCode": country_code, 186 | "popularity": "WORLDWIDE" 187 | } 188 | headers = { 189 | "Authorization": f"Bearer {self.access_token}", 190 | "Content-Type": "application/vnd.tidal.v1+json", 191 | "Accept": "application/vnd.tidal.v1+json" 192 | } 193 | 194 | response = self.session.get(url, headers=headers, params=params) 195 | response.raise_for_status() 196 | return response.json() 197 | 198 | def search_videos(self, query: str, country_code: str, limit: int = 30): 199 | self.authenticate() 200 | url = "https://openapi.tidal.com/search" 201 | params = { 202 | "query": query, 203 | "type": "VIDEOS", 204 | "offset": 0, 205 | "limit": limit, 206 | "countryCode": country_code, 207 | "popularity": "WORLDWIDE" 208 | } 209 | headers = { 210 | "Authorization": f"Bearer {self.access_token}", 211 | "Content-Type": "application/vnd.tidal.v1+json", 212 | "Accept": "application/vnd.tidal.v1+json" 213 | } 214 | 215 | response = self.session.get(url, headers=headers, params=params) 216 | response.raise_for_status() 217 | return response.json() 218 | 219 | def main(): 220 | parser = argparse.ArgumentParser(description="Tidal API script") 221 | parser.add_argument('--country-code', default='US', help='Country code (default: US)') 222 | parser.add_argument('--get-track', help='Track ID to get details for') 223 | parser.add_argument('--get-album', help='Album ID to get details for') 224 | parser.add_argument('--get-track-list', help="Album URL or ID to fetch track list (redirects to --get-album)") 225 | parser.add_argument('--search-track', help='Track name to search for') 226 | parser.add_argument('--search-album', help='Album name to search for') 227 | parser.add_argument('--get-stream', help='Track ID to get stream URL for') 228 | parser.add_argument('--user-id', help='User ID for stream URL request') 229 | parser.add_argument('--user-token', help='User access token for stream URL request') 230 | parser.add_argument('--search-video', help='Video name to search for') 231 | parser.add_argument('--search-playlist', help='Playlist name to search for') 232 | parser.add_argument('--search-artist', help='Artist name to search for') 233 | 234 | args = parser.parse_args() 235 | 236 | tidal_api = TidalAPI() 237 | 238 | try: 239 | if args.get_track: 240 | track_details = tidal_api.get_track(track_id=args.get_track, country_code=args.country_code) 241 | print(json.dumps(track_details, indent=4)) 242 | 243 | if args.get_album: 244 | album_details = tidal_api.get_album(album_id=args.get_album, country_code=args.country_code) 245 | print(json.dumps(album_details, indent=4)) 246 | 247 | # Handle --get-track-list as a redirect to --get-album 248 | if args.get_track_list: 249 | # Extract the album ID from the input (supports URLs like "album/{albumId}") 250 | if "album/" in args.get_track_list: 251 | album_id = args.get_track_list.split("album/")[-1].split("/")[0] 252 | else: 253 | album_id = args.get_track_list 254 | album_details = tidal_api.get_album(album_id=album_id, country_code=args.country_code) 255 | print(json.dumps(album_details, indent=4)) 256 | 257 | if args.search_track: 258 | search_results = tidal_api.search_tracks(query=args.search_track, country_code=args.country_code) 259 | print(json.dumps(search_results, indent=4)) 260 | 261 | if args.search_album: 262 | search_results = tidal_api.search_albums(query=args.search_album, country_code=args.country_code) 263 | print(json.dumps(search_results, indent=4)) 264 | 265 | if args.search_video: 266 | search_results = tidal_api.search_videos(query=args.search_video, country_code=args.country_code) 267 | print(json.dumps(search_results, indent=4)) 268 | 269 | if args.search_playlist: 270 | search_results = tidal_api.search_playlists(query=args.search_playlist, country_code=args.country_code) 271 | print(json.dumps(search_results, indent=4)) 272 | 273 | if args.search_artist: 274 | search_results = tidal_api.search_artists(query=args.search_artist, country_code=args.country_code) 275 | print(json.dumps(search_results, indent=4)) 276 | 277 | if args.get_stream: 278 | stream_url_data = tidal_api.get_stream_url( 279 | track_id=args.get_stream, 280 | country_code=args.country_code, 281 | user_id=args.user_id, 282 | user_token=args.user_token 283 | ) 284 | print("Stream URL Data:") 285 | print(json.dumps(stream_url_data, indent=4)) 286 | 287 | except requests.exceptions.RequestException as e: 288 | print(f"Error: {e}") 289 | if hasattr(e, 'response') and e.response is not None: 290 | print(f"Response status code: {e.response.status_code}") 291 | print(f"Response content: {e.response.content}") 292 | 293 | if __name__ == "__main__": 294 | main() -------------------------------------------------------------------------------- /src/funcs/apis/ytaudiostream.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import yt_dlp 3 | 4 | def get_stream_url(youtube_url): 5 | """ 6 | Get the best quality audio stream URL for a YouTube video. 7 | 8 | Parameters: 9 | youtube_url (str): The full YouTube video URL. 10 | 11 | Returns: 12 | str: The best quality stream URL. 13 | """ 14 | try: 15 | ydl_opts = { 16 | 'format': 'bestaudio[ext=m4a]', # Limit to the best audio in m4a format 17 | 'quiet': True, # Suppress output 18 | 'noplaylist': True, # Avoid fetching playlist information 19 | 'extractor_args': {'youtube': {'skip': 'hls'}} # Skip HLS formats 20 | } 21 | 22 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 23 | info_dict = ydl.extract_info(youtube_url, download=False) 24 | return info_dict['url'] if 'url' in info_dict else "N/A" 25 | 26 | except Exception as e: 27 | print(f"Error getting stream URL: {e}") 28 | return "N/A" 29 | 30 | if __name__ == "__main__": 31 | parser = argparse.ArgumentParser(description="Get the best quality stream URL from a YouTube video URL.") 32 | parser.add_argument('--url', required=True, help="The full YouTube video URL") 33 | 34 | args = parser.parse_args() 35 | youtube_url = args.url 36 | 37 | # Fetch the stream URL 38 | stream_url = get_stream_url(youtube_url) 39 | print(stream_url) 40 | -------------------------------------------------------------------------------- /src/funcs/apis/ytmusicsearchapi.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import json 3 | from ytmusicapi import YTMusic 4 | 5 | # Initialize the YTMusic API 6 | ytmusic = YTMusic() 7 | 8 | def get_track_list(content_id, content_type): 9 | """ 10 | Get track list for an album or playlist. 11 | 12 | Parameters: 13 | content_id (str): The ID of the album or playlist 14 | content_type (str): Either 'album' or 'playlist' 15 | 16 | Returns: 17 | dict: Album information and track list in a format matching the app structure 18 | """ 19 | try: 20 | if content_type == 'album': 21 | content = ytmusic.get_album(content_id) 22 | 23 | total_duration = sum( 24 | sum(int(t) * (60 ** i) for i, t in enumerate(reversed(track.get('duration', '0:00').split(':')))) 25 | for track in content.get('tracks', []) 26 | ) 27 | 28 | album_info = { 29 | "title": content.get('title', 'Unknown Album'), 30 | "artist": content.get('artists', [{'name': 'Unknown Artist'}])[0]['name'], 31 | "coverUrl": content.get('thumbnails', [])[-1]['url'] if content.get('thumbnails') else '', 32 | "description": content.get('description', ''), 33 | "duration": total_duration 34 | } 35 | 36 | tracks = [{ 37 | "id": track.get('videoId', ''), 38 | "number": index + 1, 39 | "title": track.get('title', 'Unknown Title'), 40 | "duration": sum(int(t) * (60 ** i) for i, t in enumerate(reversed(track.get('duration', '0:00').split(':')))), 41 | "quality": '256Kbps', 42 | "playUrl": f"https://music.youtube.com/watch?v={track.get('videoId')}" if track.get('videoId') else None 43 | } for index, track in enumerate(content.get('tracks', []))] 44 | 45 | return { 46 | "album": album_info, 47 | "tracks": tracks 48 | } 49 | 50 | elif content_type == 'playlist': 51 | content = ytmusic.get_playlist(content_id) 52 | 53 | total_duration = sum( 54 | sum(int(t) * (60 ** i) for i, t in enumerate(reversed(track.get('duration', '0:00').split(':')))) 55 | for track in content.get('tracks', []) 56 | ) 57 | 58 | playlist_info = { 59 | "title": content.get('title', 'Unknown Playlist'), 60 | "artist": content.get('author', {}).get('name', 'Unknown Creator'), 61 | "releaseDate": '', 62 | "coverUrl": content.get('thumbnails', [])[-1]['url'] if content.get('thumbnails') else '', 63 | "description": content.get('description', ''), 64 | "duration": total_duration 65 | } 66 | 67 | tracks = [{ 68 | "id": track.get('videoId', ''), 69 | "number": index + 1, 70 | "title": track.get('title', 'Unknown Title'), 71 | "duration": sum(int(t) * (60 ** i) for i, t in enumerate(reversed(track.get('duration', '0:00').split(':')))), 72 | "quality": 'HIGH', 73 | "playUrl": f"https://music.youtube.com/watch?v={track.get('videoId')}" if track.get('videoId') else None 74 | } for index, track in enumerate(content.get('tracks', []))] 75 | 76 | return { 77 | "album": playlist_info, 78 | "tracks": tracks 79 | } 80 | 81 | else: 82 | raise ValueError("Invalid content type. Must be 'album' or 'playlist'") 83 | except Exception as e: 84 | raise Exception(f"Error fetching {content_type} tracks: {str(e)}") 85 | 86 | def search_youtube_music(query, search_type="songs", raw_response=False): 87 | """ 88 | Search YouTube Music for tracks, albums, playlists, podcasts, or artists. 89 | 90 | Parameters: 91 | query (str): The search query. 92 | search_type (str): The type of content to search for. 93 | raw_response (bool): If True, returns the raw API response for podcasts and episodes. 94 | 95 | Returns: 96 | list: A list of search results based on the search_type. 97 | """ 98 | search_type_map = { 99 | 'song': 'songs', 100 | 'album': 'albums', 101 | 'playlist': 'playlists', 102 | 'artist': 'artists', 103 | 'podcast': 'podcasts', 104 | 'episode': 'episodes' 105 | } 106 | 107 | if search_type not in search_type_map: 108 | raise ValueError(f"Invalid search type: {search_type}. Valid types are {list(search_type_map.keys())}.") 109 | 110 | search_filter = search_type_map[search_type] 111 | search_results = ytmusic.search(query, filter=search_filter) 112 | 113 | # Return raw response for podcasts and episodes if requested 114 | if raw_response and search_type in ['podcast', 'episode']: 115 | return search_results 116 | 117 | formatted_results = [] 118 | 119 | if search_type == 'song': 120 | for result in search_results: 121 | if 'album' in result: 122 | formatted_results.append({ 123 | "TrackTitle": result.get('title', 'Unknown Title'), 124 | "AlbumTitle": result['album'].get('name', 'Unknown Album'), 125 | "AlbumCover": result['thumbnails'][-1]['url'], 126 | "ArtistName": result['artists'][0].get('name', 'Unknown Artist') if result.get('artists') else 'Unknown Artist', 127 | "TrackURL": f"https://music.youtube.com/watch?v={result['videoId']}" if 'videoId' in result else 'N/A', 128 | "Explicit": result.get('isExplicit', False) 129 | }) 130 | elif search_type == 'album': 131 | for result in search_results: 132 | formatted_results.append({ 133 | "AlbumTitle": result.get('title', 'Unknown Album'), 134 | "AlbumCover": result['thumbnails'][-1]['url'] if result.get('thumbnails') else 'N/A', 135 | "ArtistName": result['artists'][0].get('name', 'Unknown Artist') if result.get('artists') else 'Unknown Artist', 136 | "AlbumURL": f"https://music.youtube.com/browse/{result['browseId']}" if 'browseId' in result else 'N/A', 137 | "Explicit": result.get('isExplicit', False), 138 | "BrowseId": result.get('browseId', 'N/A') 139 | }) 140 | elif search_type == 'playlist': 141 | for result in search_results: 142 | formatted_results.append({ 143 | "PlaylistTitle": result.get('title', 'Unknown Playlist'), 144 | "PlaylistCover": result['thumbnails'][-1]['url'] if result.get('thumbnails') else 'N/A', 145 | "Author": result.get('author', 'Unknown Author'), 146 | "PlaylistURL": f"https://music.youtube.com/browse/{result['browseId']}" if 'browseId' in result else 'N/A', 147 | "BrowseId": result.get('browseId', 'N/A') 148 | }) 149 | elif search_type == 'artist': 150 | for result in search_results: 151 | formatted_results.append({ 152 | "ArtistName": result.get('artist', result.get('title', 'Unknown Artist')), 153 | "ArtistCover": result['thumbnails'][-1]['url'] if result.get('thumbnails') else 'N/A', 154 | "ArtistURL": f"https://music.youtube.com/browse/{result['browseId']}" if 'browseId' in result else 'N/A' 155 | }) 156 | elif search_type == 'podcast': 157 | for result in search_results: 158 | formatted_results.append({ 159 | "PodcastTitle": result.get('title', 'Unknown Podcast'), 160 | "PodcastCover": result['thumbnails'][-1]['url'] if result.get('thumbnails') else 'N/A', 161 | "PodcastURL": f"https://music.youtube.com/browse/{result['browseId']}" if 'browseId' in result else 'N/A' 162 | }) 163 | elif search_type == 'episode': 164 | for result in search_results: 165 | formatted_results.append({ 166 | "EpisodeTitle": result.get('title', 'Unknown Title'), 167 | "EpisodeCover": result['thumbnails'][-1]['url'] if result.get('thumbnails') else 'N/A', 168 | "Podcast": result['podcast']['name'], 169 | "EpisodeURL": f"https://music.youtube.com/watch?v={result['videoId']}" if 'videoId' in result else 'N/A', 170 | }) 171 | return formatted_results 172 | 173 | if __name__ == "__main__": 174 | parser = argparse.ArgumentParser(description="Search YouTube Music for songs, albums, playlists, podcasts, or artists.") 175 | parser.add_argument('-q', '--query', help="The search query") 176 | parser.add_argument('-t', '--type', choices=['song', 'album', 'playlist', 'artist', 'podcast', 'episode'], 177 | default='song', help="The type of content to search for") 178 | parser.add_argument('-r', '--raw', action='store_true', 179 | help="Return raw API response for podcasts and episodes") 180 | parser.add_argument('--get-track-list', help="Get track list for album/ID or playlist/ID (format: 'album/ID' or 'playlist/ID')") 181 | 182 | args = parser.parse_args() 183 | 184 | try: 185 | if args.get_track_list: 186 | content_type, content_id = args.get_track_list.split('/') 187 | if content_type not in ['album', 'playlist']: 188 | raise ValueError("Track list can only be retrieved for albums or playlists") 189 | results = get_track_list(content_id, content_type) 190 | elif args.query: 191 | results = search_youtube_music(args.query, args.type, args.raw) 192 | else: 193 | parser.error("Either --query or --get-track-list must be specified") 194 | 195 | if results: 196 | print(json.dumps(results, indent=4)) 197 | else: 198 | print("No results found.") 199 | except Exception as e: 200 | print(f"Error: {str(e)}") -------------------------------------------------------------------------------- /src/funcs/apis/ytsearchapi.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import isodate 3 | import json 4 | import sys 5 | import os 6 | import requests 7 | 8 | class YouTubeSearch: 9 | BASE_URL = 'https://www.googleapis.com/youtube/v3' 10 | 11 | def __init__(self): 12 | self.api_key = self._load_api_key() 13 | 14 | def _load_api_key(self): 15 | try: 16 | if os.path.exists('apis.json'): 17 | config_path = 'apis.json' 18 | else: 19 | script_dir = os.path.dirname(os.path.abspath(__file__)) 20 | config_path = os.path.join(script_dir, 'apis.json') 21 | if not os.path.exists(config_path): 22 | raise FileNotFoundError("apis.json not found") 23 | 24 | with open(config_path, 'r') as f: 25 | config = json.load(f) 26 | if 'YOUTUBE_API_KEY' not in config: 27 | raise KeyError("YOUTUBE_API_KEY not found in apis.json") 28 | return config['YOUTUBE_API_KEY'] 29 | except Exception as e: 30 | print(json.dumps({'error': f"Failed to load API key: {str(e)}"}, indent=2)) 31 | sys.exit(1) 32 | 33 | def _make_request(self, endpoint, params): 34 | params['key'] = self.api_key 35 | try: 36 | response = requests.get(f"{self.BASE_URL}/{endpoint}", params=params) 37 | response.raise_for_status() 38 | return response.json() 39 | except requests.exceptions.RequestException as e: 40 | return {'error': str(e)} 41 | 42 | def search_videos(self, query, max_results=5): 43 | params = { 44 | 'q': query, 45 | 'part': 'snippet', 46 | 'type': 'video', # Ensure we're searching for videos 47 | 'maxResults': max_results 48 | } 49 | response = self._make_request('search', params) 50 | if 'error' in response: 51 | return {'error': response['error']} 52 | 53 | results = [] 54 | for item in response.get('items', []): 55 | # Check if the item is a video (should have 'videoId' in 'id') 56 | if 'videoId' not in item['id']: 57 | # If it's not a video, skip it or handle it differently 58 | continue 59 | 60 | result = { 61 | 'Thumbnail': item['snippet']['thumbnails']['medium']['url'], 62 | 'Video Title': item['snippet']['title'], 63 | 'Channel Title': item['snippet']['channelTitle'], 64 | 'Video URL': f"https://www.youtube.com/watch?v={item['id']['videoId']}" 65 | } 66 | results.append(result) 67 | return results 68 | 69 | 70 | def search_playlists(self, query, max_results=5): 71 | params = { 72 | 'q': query, 73 | 'part': 'snippet', 74 | 'type': 'playlist', 75 | 'maxResults': max_results 76 | } 77 | response = self._make_request('search', params) 78 | if 'error' in response: 79 | return {'error': response['error']} 80 | 81 | results = [] 82 | for item in response.get('items', []): 83 | result = { 84 | 'Thumbnail': item['snippet']['thumbnails']['medium']['url'], 85 | 'Playlist Title': item['snippet']['title'], 86 | 'Channel Title': item['snippet']['channelTitle'], 87 | 'Playlist URL': f"https://www.youtube.com/playlist?list={item['id']['playlistId']}", 88 | 'BrowseId': item['id']['playlistId'] # Adding BrowseId for playlists 89 | } 90 | results.append(result) 91 | return results 92 | 93 | 94 | def search_channels(self, query, max_results=5): 95 | params = { 96 | 'q': query, 97 | 'part': 'snippet', 98 | 'type': 'channel', 99 | 'maxResults': max_results 100 | } 101 | response = self._make_request('search', params) 102 | if 'error' in response: 103 | return {'error': response['error']} 104 | 105 | results = [] 106 | for item in response.get('items', []): 107 | result = { 108 | 'Thumbnail': item['snippet']['thumbnails']['medium']['url'], 109 | 'Channel Title': item['snippet']['title'], 110 | 'Channel ID': item['snippet']['channelId'], 111 | 'Channel URL': f"https://www.youtube.com/channel/{item['snippet']['channelId']}", 112 | 'BrowseId': item['snippet']['channelId'] # Adding BrowseId for channels 113 | } 114 | results.append(result) 115 | return results 116 | 117 | 118 | def get_playlist_details(self, playlist_id): 119 | params = { 120 | 'part': 'snippet,contentDetails', 121 | 'id': playlist_id 122 | } 123 | response = self._make_request('playlists', params) 124 | if 'error' in response: 125 | return {'error': response['error']} 126 | 127 | if not response.get('items'): 128 | return {'error': 'Playlist not found'} 129 | 130 | item = response['items'][0] 131 | playlist_details = { 132 | 'title': item['snippet']['title'], 133 | 'artist': item['snippet']['channelTitle'], 134 | 'releaseDate': item['snippet']['publishedAt'], 135 | 'coverUrl': item['snippet']['thumbnails']['medium']['url'], 136 | 'description': item['snippet']['description'], 137 | 'duration': item['contentDetails']['itemCount'] 138 | } 139 | return playlist_details 140 | 141 | def _get_duration_seconds(self, iso_duration): 142 | try: 143 | duration = isodate.parse_duration(iso_duration) 144 | return int(duration.total_seconds()) 145 | except Exception: 146 | return 0 147 | 148 | def get_playlist_tracks(self, playlist_id): 149 | playlist_params = { 150 | 'part': 'snippet,contentDetails', 151 | 'playlistId': playlist_id, 152 | 'maxResults': 50 153 | } 154 | video_ids = [] 155 | tracks = [] 156 | 157 | while True: 158 | response = self._make_request('playlistItems', playlist_params) 159 | if 'error' in response: 160 | return {'error': response['error']} 161 | 162 | for item in response.get('items', []): 163 | video_ids.append(item['contentDetails']['videoId']) 164 | snippet = item.get('snippet', {}) 165 | thumbnails = snippet.get('thumbnails', {}) 166 | 167 | cover_url = ( 168 | thumbnails.get('medium', {}).get('url') or 169 | thumbnails.get('default', {}).get('url') or 170 | None 171 | ) 172 | 173 | tracks.append({ 174 | 'id': item['contentDetails']['videoId'], 175 | 'number': len(tracks) + 1, 176 | 'title': snippet.get('title', 'Unknown'), 177 | 'artist': snippet.get('videoOwnerChannelTitle', 'Unknown'), 178 | 'playUrl': f"https://www.youtube.com/watch?v={item['contentDetails']['videoId']}", 179 | 'coverUrl': cover_url 180 | }) 181 | 182 | if 'nextPageToken' in response: 183 | playlist_params['pageToken'] = response['nextPageToken'] 184 | else: 185 | break 186 | 187 | for i in range(0, len(video_ids), 50): 188 | video_params = { 189 | 'part': 'contentDetails', 190 | 'id': ','.join(video_ids[i:i+50]) 191 | } 192 | video_response = self._make_request('videos', video_params) 193 | if 'error' in video_response: 194 | return {'error': video_response['error']} 195 | 196 | for idx, video_item in enumerate(video_response.get('items', [])): 197 | duration_str = video_item.get('contentDetails', {}).get('duration', 'PT0S') 198 | duration_seconds = self._get_duration_seconds(duration_str) 199 | tracks[i + idx]['duration'] = duration_seconds 200 | 201 | return tracks 202 | 203 | def main(): 204 | parser = argparse.ArgumentParser(description='YouTube Search API with Playlist Details') 205 | parser.add_argument('-q', '--query', help='Search query') 206 | parser.add_argument('-t', '--type', choices=['video', 'playlist', 'channel'], 207 | default='video', help='Type of search') 208 | parser.add_argument('-m', '--max-results', type=int, default=10, 209 | help='Maximum number of results') 210 | parser.add_argument('--get-track-list', metavar='playlist/ID', 211 | help='Get track list and details of a playlist by ID') 212 | 213 | args = parser.parse_args() 214 | yt_search = YouTubeSearch() 215 | 216 | try: 217 | if args.get_track_list: 218 | playlist_id = args.get_track_list.split('/')[-1] 219 | playlist_details = yt_search.get_playlist_details(playlist_id) 220 | if 'error' in playlist_details: 221 | print(json.dumps(playlist_details, indent=2)) 222 | sys.exit(1) 223 | tracks = yt_search.get_playlist_tracks(playlist_id) 224 | if 'error' in tracks: 225 | print(json.dumps(tracks, indent=2)) 226 | sys.exit(1) 227 | result = { 228 | 'Playlist': playlist_details, 229 | 'Tracks': tracks 230 | } 231 | print(json.dumps(result, indent=2)) 232 | else: 233 | if args.type == 'video': 234 | results = yt_search.search_videos(args.query, args.max_results) 235 | elif args.type == 'playlist': 236 | results = yt_search.search_playlists(args.query, args.max_results) 237 | elif args.type == 'channel': 238 | results = yt_search.search_channels(args.query, args.max_results) 239 | print(json.dumps(results, indent=2)) 240 | 241 | except Exception as e: 242 | print(json.dumps({'error': str(e)}, indent=2)) 243 | sys.exit(1) 244 | 245 | if __name__ == "__main__": 246 | main() 247 | -------------------------------------------------------------------------------- /src/funcs/apis/ytvideostream.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import yt_dlp 3 | 4 | def get_combined_stream_url(youtube_url): 5 | """ 6 | Get a combined audio and video stream URL for a YouTube video. 7 | Parameters: 8 | youtube_url (str): The full YouTube video URL. 9 | Returns: 10 | str: The URL of the best combined (audio + video) stream. 11 | """ 12 | try: 13 | ydl_opts = { 14 | 'format': '22/bestvideo+bestaudio/best', # Prioritize format 22, then fallback to best combined 15 | 'quiet': True, # Enable output for debugging 16 | 'verbose': False, # More verbose output to see what's happening 17 | 'no_warnings': False, # Show warnings 18 | 'youtube_include_dash_manifest': True, # Include DASH manifests 19 | 'extractor_args': { 20 | 'youtube': { 21 | 'player_client': ['android', 'web'], # Try different clients 22 | 'skip': ['hls', 'dash'] # Skip HLS and DASH formats if causing issues 23 | } 24 | } 25 | } 26 | 27 | with yt_dlp.YoutubeDL(ydl_opts) as ydl: 28 | info_dict = ydl.extract_info(youtube_url, download=False) 29 | 30 | # Try to get a direct playable URL from the formats 31 | if 'formats' in info_dict: 32 | # First try to find format 22 (usually 720p with audio) 33 | for format in info_dict['formats']: 34 | if format.get('format_id') == '22' and 'url' in format: 35 | return format['url'] 36 | 37 | # If format 22 not found, try any format with both video and audio 38 | for format in info_dict['formats']: 39 | if ('vcodec' in format and format['vcodec'] != 'none' and 40 | 'acodec' in format and format['acodec'] != 'none' and 41 | 'url' in format): 42 | return format['url'] 43 | 44 | # As a last resort, try any format with a URL 45 | for format in info_dict['formats']: 46 | if 'url' in format: 47 | return format['url'] 48 | 49 | # If direct URL is in the info_dict 50 | if 'url' in info_dict: 51 | return info_dict['url'] 52 | 53 | # If no valid URL is found 54 | return "No valid stream URL found" 55 | except Exception as e: 56 | print(f"Error fetching combined stream URL: {e}") 57 | return f"Error: {str(e)}" 58 | 59 | if __name__ == "__main__": 60 | # Set up argument parser 61 | parser = argparse.ArgumentParser(description="Get a combined audio + video stream URL from a YouTube video URL.") 62 | parser.add_argument('--url', required=True, help="The full YouTube video URL") 63 | 64 | # Parse the arguments 65 | args = parser.parse_args() 66 | youtube_url = args.url 67 | 68 | # Fetch the combined stream URL 69 | combined_url = get_combined_stream_url(youtube_url) 70 | 71 | # Print the result 72 | print(combined_url) 73 | -------------------------------------------------------------------------------- /src/funcs/db.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const path = require("path"); 3 | const { getDefaultSettings } = require('./defaults'); 4 | const { app } = require("electron"); 5 | const sqlite3 = require('sqlite3').verbose(); 6 | 7 | const settingsFilePath = path.join(app.getPath('userData'), 'mh-settings.json'); 8 | 9 | function loadSettings() { 10 | try { 11 | const settingsData = fs.readFileSync(settingsFilePath, 'utf8'); 12 | return JSON.parse(settingsData); 13 | } catch (err) { 14 | console.log('No user settings found, using default settings.'); 15 | return getDefaultSettings(); // Fall back to default settings 16 | } 17 | } 18 | 19 | const userSettings = loadSettings(); 20 | const dbPath = userSettings.downloadsDatabasePath || path.join(app.getPath('userData'), 'downloads_database.db'); 21 | 22 | // Ensure the directory for the database exists 23 | const dbDir = path.dirname(dbPath); 24 | if (!fs.existsSync(dbDir)) { 25 | try { 26 | fs.mkdirSync(dbDir, { recursive: true }); 27 | console.log(`Created directory: ${dbDir}`); 28 | } catch (err) { 29 | console.error(`Error creating directory ${dbDir}:`, err.message); 30 | // Depending on your application's requirements, you might want to: 31 | // - Exit the process 32 | // - Use a fallback directory 33 | // - Notify the user 34 | // For example: 35 | // process.exit(1); 36 | } 37 | } 38 | 39 | let db = new sqlite3.Database(dbPath, (err) => { 40 | if (err) { 41 | console.error('Error opening the database:', err.message); 42 | } else { 43 | console.log('Connected to the SQLite database.'); 44 | // Create the table if it doesn't exist 45 | db.run(`CREATE TABLE IF NOT EXISTS downloads ( 46 | id INTEGER PRIMARY KEY AUTOINCREMENT, 47 | downloadName TEXT, 48 | downloadArtistOrUploader TEXT, 49 | downloadLocation TEXT, 50 | downloadThumbnail TEXT 51 | )`, (err) => { 52 | if (err) { 53 | console.error('Error creating downloads table:', err.message); 54 | } else { 55 | console.log('Downloads table is ready.'); 56 | } 57 | }); 58 | } 59 | }); 60 | 61 | function saveDownloadToDatabase(downloadInfo) { 62 | const sql = `INSERT INTO downloads (downloadName, downloadArtistOrUploader, downloadLocation, downloadThumbnail) 63 | VALUES (?, ?, ?, ?)`; 64 | 65 | db.run(sql, [downloadInfo.downloadName, downloadInfo.downloadArtistOrUploader, downloadInfo.downloadLocation, downloadInfo.downloadThumbnail], function (err) { 66 | if (err) { 67 | return console.error('Error saving download to database:', err.message); 68 | } 69 | console.log(`A row has been inserted with rowid ${this.lastID}`); 70 | }); 71 | } 72 | 73 | function loadDownloadsFromDatabase(callback) { 74 | const sql = `SELECT * FROM downloads`; 75 | 76 | db.all(sql, [], (err, rows) => { 77 | if (err) { 78 | console.error('Error loading downloads from database:', err.message); 79 | callback([]); // Return empty array on error 80 | return; 81 | } 82 | callback(rows); 83 | }); 84 | } 85 | 86 | function deleteFromDatabase(event, id) { 87 | return new Promise((resolve, reject) => { 88 | const sql = 'DELETE FROM downloads WHERE id = ?'; 89 | db.run(sql, [id], (err) => { 90 | if (err) { 91 | console.error('Error deleting download:', err); 92 | reject(err); 93 | return; 94 | } 95 | resolve(); 96 | }); 97 | }); 98 | } 99 | 100 | async function closeDatabase() { 101 | return new Promise((resolve, reject) => { 102 | db.close((err) => { 103 | if (err) { 104 | console.error('Error closing the database:', err.message); 105 | reject(err); 106 | } else { 107 | console.log('Database connection closed.'); 108 | resolve(); 109 | } 110 | }); 111 | }); 112 | } 113 | 114 | async function reconnectDatabase() { 115 | return new Promise((resolve, reject) => { 116 | db = new sqlite3.Database(dbPath, (err) => { // Use dbPath instead of userSettings.downloadsDatabasePath for consistency 117 | if (err) { 118 | console.error('Error reopening the database:', err.message); 119 | reject(err); 120 | } else { 121 | console.log('Reconnected to the SQLite database.'); 122 | 123 | // Ensure the table is recreated 124 | db.run(`CREATE TABLE IF NOT EXISTS downloads ( 125 | id INTEGER PRIMARY KEY AUTOINCREMENT, 126 | downloadName TEXT, 127 | downloadArtistOrUploader TEXT, 128 | downloadLocation TEXT, 129 | downloadThumbnail TEXT 130 | )`, (err) => { 131 | if (err) { 132 | console.error('Error creating downloads table:', err.message); 133 | reject(err); 134 | } else { 135 | console.log('Downloads table is ready.'); 136 | resolve(); 137 | } 138 | }); 139 | } 140 | }); 141 | }); 142 | } 143 | 144 | async function deleteDataBase() { 145 | console.log(dbPath); 146 | try { 147 | // Close the database connection before deleting the file 148 | await closeDatabase(); 149 | 150 | if (fs.existsSync(dbPath)) { 151 | // Delete the database file 152 | fs.unlinkSync(dbPath); 153 | console.log('Database file deleted successfully.'); 154 | 155 | // Recreate an empty database and table 156 | await reconnectDatabase(); 157 | console.log('Database reconnected and downloads table recreated.'); 158 | 159 | return { success: true }; 160 | } else { 161 | return { success: false, message: 'File not found' }; 162 | } 163 | } catch (error) { 164 | console.error('Error during database deletion or reconnection:', error.message); 165 | return { success: false, message: error.message }; 166 | } 167 | } 168 | 169 | module.exports = { saveDownloadToDatabase, loadDownloadsFromDatabase, deleteFromDatabase, deleteDataBase }; 170 | -------------------------------------------------------------------------------- /src/funcs/defaults.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const {app} = require("electron"); 3 | 4 | function getDefaultSettings() { 5 | return { 6 | autoUpdate: true, 7 | firstTime: true, 8 | theme: 'auto', 9 | downloadsDatabasePath: path.join(app.getPath('home'), 'MH', 'database.db'), 10 | downloadLocation: app.getPath('downloads'), 11 | createPlatformSubfolders: false, 12 | orpheusDL: false, 13 | streamrip: true, 14 | use_cookies: false, 15 | cookies: "", 16 | cookies_from_browser: "", 17 | override_download_extension: false, 18 | yt_override_download_extension: false, 19 | ytm_override_download_extension: false, 20 | youtubeVideoExtensions: "mp4", 21 | youtubeAudioExtensions: "mp3", 22 | use_aria2: false, 23 | auto_update: true, 24 | max_downloads: 0, 25 | download_speed_limit: false, 26 | speed_limit_type: 'M', 27 | speed_limit_value: 0, 28 | max_retries: 5, 29 | download_output_template: "%(title)s.%(ext)s", 30 | continue: true, 31 | add_metadata: false, 32 | embed_chapters: false, 33 | add_subtitle_to_file: false, 34 | use_proxy: false, 35 | proxy_url: "", 36 | use_authentication: false, 37 | username: "", 38 | password: "", 39 | sponsorblock_mark: "all", 40 | sponsorblock_remove: "", 41 | sponsorblock_chapter_title: "[SponsorBlock]: %(category_names)l", 42 | no_sponsorblock: false, 43 | sponsorblock_api_url: "https://sponsor.ajay.app", 44 | 45 | // Streamrip settings 46 | disc_subdirectories: true, 47 | concurrency: true, 48 | max_connections: 6, 49 | requests_per_minute: 60, 50 | // Qobuz 51 | qobuz_quality: 3, 52 | qobuz_download_booklets: true, 53 | qobuz_token_or_email: false, 54 | qobuz_email_or_userid: "", 55 | qobuz_password_or_token: "", 56 | qobuz_app_id: "", 57 | qobuz_secrets: "", 58 | // Tidal 59 | tidal_download_videos: false, 60 | tidal_quality: '3', 61 | tidal_user_id: '', 62 | tidal_country_code:"", 63 | tidal_access_token:"", 64 | tidal_refresh_token: "", 65 | tidal_token_expiry: "", 66 | // Deezer 67 | deezer_quality: 1, 68 | deezer_use_deezloader: false, 69 | deezer_arl: "", 70 | deezloader_warnings: true, 71 | 72 | downloads_database_check: false, 73 | downloads_database: "", 74 | failed_downloads_database_check: false, 75 | failed_downloads_database: "", 76 | conversion_check: false, 77 | conversion_codec: "MP3", 78 | conversion_sampling_rate: 44100, 79 | conversion_bit_depth: 16, 80 | conversion_lossy_bitrate: 320, 81 | meta_album_name_playlist_check: false, 82 | meta_album_order_playlist_check: false, 83 | meta_exclude_tags_check: false, 84 | excluded_tags: "", 85 | filepaths_add_singles_to_folder: true, 86 | filepaths_folder_format: "{albumartist} - {title} ({year}) [{container}] [{bit_depth}B-{sampling_rate}kHz]", 87 | filepaths_track_format: "{tracknumber:02}. {artist} - {title}{explicit}", 88 | filepaths_restrict_characters: true, 89 | filepaths_truncate_to: 120, 90 | // These will be added to GUI after adding discography artist on search 91 | qobuz_filters_extras: false, 92 | qobuz_repeats: false, 93 | qobuz_non_albums: false, 94 | qobuz_features: false, 95 | qobuz_non_studio_albums: false, 96 | qobuz_non_remaster: false, 97 | // these to fix streamrip config issue 98 | soundcloud_quality: 0, 99 | soundcloud_client_id: "", 100 | soundcloud_app_version: "", 101 | youtube_quality: 0, 102 | youtube_download_videos: false, 103 | youtube_video_downloads_folder: "", 104 | lastfm_source: "qobuz", 105 | lastfm_fallback_source: "", 106 | cli_text_output: true, 107 | cli_progress_bars: true, 108 | cli_max_search_results: "100", 109 | // Spotify (Zotify) Settings 110 | zotify_userName: "", 111 | spotify_audio_format: "mp3", 112 | spotify_transcode_bitrate: -1, 113 | spotify_ffmpeg_args: "", 114 | spotify_download_quality: "auto", 115 | spotify_artwork_size: "large", 116 | spotify_save_subtitles: false, 117 | spotify_lyrics_file: false, 118 | spotify_lyrics_only: false, 119 | spotify_create_playlist_file: true, 120 | spotify_save_metadata: true, 121 | spotify_replace_existing: false, 122 | spotify_skip_previous: true, 123 | spotify_skip_duplicates: true, 124 | spotify_output_album: "{album_artist}/{album}/{track_number}. {artists} - {title}", 125 | spotify_output_playlist_track: "{playlist}/{artists} - {title}", 126 | spotify_output_playlist_episode: "{playlist}/{episode_number} - {title}", 127 | spotify_output_podcast: "{podcast}/{episode_number} - {title}", 128 | 129 | // Apple Settings 130 | apple_cookies_path: "", 131 | apple_output_path: "Apple Music", 132 | apple_temp_path: "temp", 133 | apple_download_mode: "ytdlp", 134 | apple_remux_mode: "ffmpeg", 135 | apple_cover_format: "jpg", 136 | apple_synced_lyrics_format: "lrc", 137 | apple_template_folder_album: "{album_artist}/{album}", 138 | apple_template_folder_compilation: "Compilations/{album}", 139 | apple_template_file_single_disc: "{track:02d} {title}", 140 | apple_template_file_multi_disc: "{disc}-{track:02d} {title}", 141 | apple_disable_music_video_skip: true, 142 | apple_save_cover: true, 143 | apple_overwrite: true, 144 | apple_save_playlist: true, 145 | apple_synced_lyrics_only: true, 146 | apple_no_synced_lyrics: false, 147 | apple_cover_size: 1200, 148 | }; 149 | } 150 | 151 | module.exports = { getDefaultSettings }; -------------------------------------------------------------------------------- /src/funcs/downloadorder.js: -------------------------------------------------------------------------------- 1 | let downloadCount = 0; 2 | 3 | const getNextDownloadOrder = () => { 4 | return ++downloadCount; 5 | }; 6 | 7 | module.exports = { getNextDownloadOrder }; -------------------------------------------------------------------------------- /src/funcs/fetchers.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const cheerio = require("cheerio"); 3 | 4 | async function fetchWebsiteTitle(url) { 5 | try { 6 | const response = await axios.get(url, { 7 | headers: { 8 | 'Cache-Control': 'no-cache', 9 | 'Pragma': 'no-cache', 10 | 'Expires': '0' 11 | } 12 | }); 13 | const html = response.data; 14 | const $ = cheerio.load(html); 15 | 16 | // Fetch the title from the tag only 17 | let title = $('title').text().trim(); 18 | 19 | // Trim the title if it's too long 20 | title = title.length > 50 ? title.slice(0, 50)+'…' : title; 21 | 22 | return title; 23 | } catch (error) { 24 | console.error('Error fetching title:', error); 25 | return 'Unknown Title'; // Fallback if there's an error 26 | } 27 | } 28 | function extractDomain(url) { 29 | try { 30 | const domain = new URL(url).hostname; 31 | return domain.startsWith('www.') ? domain.slice(4) : domain; // Remove 'www.' if present 32 | } catch (error) { 33 | console.error('Invalid URL:', error); 34 | return url; // Fallback to the full URL if invalid 35 | } 36 | } 37 | async function fetchHighResImageOrFavicon(url) { 38 | try { 39 | const response = await axios.get(url); 40 | const html = response.data; 41 | const $ = cheerio.load(html); 42 | 43 | // Look for Open Graph image first 44 | let ogImage = $('meta[property="og:image"]').attr('content'); 45 | 46 | // If no OG image, fallback to high-res favicons or Apple touch icons 47 | let favicon = $('link[rel="apple-touch-icon"]').attr('href') || 48 | $('link[rel="icon"][sizes]').attr('href') || // Look for any icon with sizes attribute 49 | $('link[rel="icon"]').attr('href') || // Fallback to normal favicon 50 | '/favicon.ico'; // Fallback to default favicon 51 | 52 | // If we found an OG image, return that 53 | let image = ogImage || favicon; 54 | 55 | // If the image URL is relative, make it absolute by combining with base URL 56 | if (!image.startsWith('http')) { 57 | const baseUrl = new URL(url).origin; 58 | image = `${baseUrl}${image}`; 59 | } 60 | 61 | return image; 62 | } catch (error) { 63 | console.error('Error fetching image:', error); 64 | return '/favicon.ico'; // Fallback to generic favicon 65 | } 66 | } 67 | 68 | module.exports = {fetchWebsiteTitle, extractDomain, fetchHighResImageOrFavicon}; -------------------------------------------------------------------------------- /src/funcs/installers/bento4installer.js: -------------------------------------------------------------------------------- 1 | const os = require("os"); 2 | const path = require("path"); 3 | const fs = require("fs"); 4 | const axios = require("axios"); 5 | const ProgressBar = require("progress"); 6 | const unzipper = require("unzipper"); 7 | const { spawn } = require("child_process"); 8 | 9 | const config = { 10 | version: "1-6-0-641", 11 | baseUrl: "https://www.bok.net/Bento4/binaries", 12 | installDir: process.env.BENTO4_INSTALL_DIR || null, 13 | }; 14 | 15 | function getDownloadInfo(platform, arch, version) { 16 | let downloadUrl; 17 | let defaultInstallDir; 18 | 19 | const versionedPath = `Bento4-SDK-${version}`; 20 | 21 | if (platform === 'win32') { 22 | const archSuffix = arch === 'x64' ? 'x86_64-microsoft-win32' : 'x86-microsoft-win32'; 23 | downloadUrl = `${config.baseUrl}/${versionedPath}.${archSuffix}.zip`; 24 | defaultInstallDir = path.join(os.homedir(), 'Bento4'); 25 | } else if (platform === 'darwin') { 26 | downloadUrl = `${config.baseUrl}/${versionedPath}.universal-apple-macosx.zip`; 27 | defaultInstallDir = path.join('/Applications', 'Bento4'); 28 | } else if (platform === 'linux') { 29 | const archSuffix = arch === 'x64' ? 'x86_64-unknown-linux' : `${arch}-unknown-linux`; 30 | downloadUrl = `${config.baseUrl}/${versionedPath}.${archSuffix}.zip`; 31 | defaultInstallDir = path.join(os.homedir(), 'Bento4'); 32 | } else { 33 | throw new Error(`Unsupported platform: ${platform}`); 34 | } 35 | 36 | return { downloadUrl, defaultInstallDir }; 37 | } 38 | 39 | function createLogger(mainWin) { 40 | return (message) => { 41 | if (mainWin && typeof mainWin.webContents.send === 'function') { 42 | mainWin.webContents.send('log', message); 43 | } else { 44 | console.log(message); 45 | } 46 | }; 47 | } 48 | 49 | async function addBento4ToPath(binDir, platform, log, mainWin) { 50 | if (platform === 'darwin') { 51 | const files = fs.readdirSync(binDir); 52 | for (const file of files) { 53 | const filePath = path.join(binDir, file); 54 | try { 55 | fs.chmodSync(filePath, '755'); 56 | log(`Set executable permissions for ${file}`); 57 | } catch (error) { 58 | log(`Error setting permissions for ${file}: ${error.message}`); 59 | } 60 | } 61 | 62 | const shellFiles = [ 63 | path.join(os.homedir(), '.zshrc'), 64 | path.join(os.homedir(), '.bash_profile') 65 | ]; 66 | 67 | for (const rcFile of shellFiles) { 68 | try { 69 | const exportCommand = `\n# Added by Bento4 installer\nexport PATH="${binDir}:$PATH"\n`; 70 | 71 | if (fs.existsSync(rcFile)) { 72 | const rcContent = fs.readFileSync(rcFile, 'utf8'); 73 | if (!rcContent.includes(binDir)) { 74 | fs.appendFileSync(rcFile, exportCommand); 75 | log(`Added Bento4 to PATH in ${rcFile}`); 76 | } else { 77 | log(`Bento4 already in PATH in ${rcFile}`); 78 | } 79 | } else { 80 | fs.writeFileSync(rcFile, exportCommand); 81 | log(`Created ${rcFile} with Bento4 PATH`); 82 | } 83 | } catch (error) { 84 | log(`Error updating ${rcFile}: ${error.message}`); 85 | } 86 | } 87 | 88 | const localBinDir = '/usr/local/bin'; 89 | try { 90 | if (!fs.existsSync(localBinDir)) { 91 | fs.mkdirSync(localBinDir, { recursive: true }); 92 | } 93 | 94 | files.forEach(file => { 95 | const sourcePath = path.join(binDir, file); 96 | const targetPath = path.join(localBinDir, file); 97 | 98 | try { 99 | if (fs.existsSync(targetPath)) { 100 | fs.unlinkSync(targetPath); 101 | } 102 | fs.symlinkSync(sourcePath, targetPath); 103 | log(`Created symlink for ${file} in ${localBinDir}`); 104 | } catch (error) { 105 | log(`Error creating symlink for ${file}: ${error.message}`); 106 | } 107 | }); 108 | } catch (error) { 109 | log(`Error accessing ${localBinDir}: ${error.message}`); 110 | } 111 | 112 | return; 113 | } 114 | 115 | if (platform === 'win32') { 116 | return new Promise((resolve, reject) => { 117 | const normalizedBinDir = binDir.replace(/\//g, '\\'); 118 | const psCommand = ` 119 | $userPath = [Environment]::GetEnvironmentVariable('Path', 'User') 120 | $binDir = '${normalizedBinDir}' 121 | 122 | if ($userPath -split ';' -notcontains $binDir) { 123 | $newPath = $userPath + ';' + $binDir 124 | [Environment]::SetEnvironmentVariable('Path', $newPath, 'User') 125 | Write-Output "Added Bento4 to PATH" 126 | } else { 127 | Write-Output "Bento4 already in PATH" 128 | } 129 | `; 130 | 131 | const psProcess = spawn('powershell.exe', [ 132 | '-NoProfile', 133 | '-NonInteractive', 134 | '-Command', 135 | psCommand 136 | ]); 137 | 138 | let errorOutput = ''; 139 | let output = ''; 140 | 141 | psProcess.stdout.on('data', (data) => { 142 | output += data.toString(); 143 | }); 144 | 145 | psProcess.stderr.on('data', (data) => { 146 | errorOutput += data.toString(); 147 | mainWin.webContents.send('installation-progress', { 148 | percent: 90, 149 | status: 'Configuring system PATH...' 150 | }); 151 | }); 152 | 153 | psProcess.on('close', (code) => { 154 | if (code === 0) { 155 | log(output.trim()); 156 | const executableExtensions = ['.exe', '']; 157 | fs.readdirSync(normalizedBinDir).forEach(file => { 158 | const ext = path.extname(file); 159 | const baseName = path.basename(file, ext); 160 | 161 | if (executableExtensions.includes(ext)) { 162 | const cmdPath = path.join(normalizedBinDir, `${baseName}.cmd`); 163 | const cmdContent = `@echo off\n"%~dp0${file}" %*`; 164 | fs.writeFileSync(cmdPath, cmdContent); 165 | } 166 | }); 167 | resolve(); 168 | } else { 169 | reject(new Error(`Failed to update PATH: ${errorOutput || 'Unknown error'}`)); 170 | } 171 | }); 172 | }); 173 | } else { 174 | const shell = process.env.SHELL || '/bin/bash'; 175 | let rcFile; 176 | if (shell.includes('zsh')) { 177 | rcFile = path.join(os.homedir(), '.zshrc'); 178 | } else if (shell.includes('bash')) { 179 | rcFile = path.join(os.homedir(), '.bashrc'); 180 | } else { 181 | rcFile = path.join(os.homedir(), '.profile'); 182 | } 183 | 184 | const exportCommand = `\n# Added by Bento4 installer\nexport PATH="${binDir}:$PATH"\n`; 185 | 186 | if (fs.existsSync(rcFile)) { 187 | const rcFileContent = fs.readFileSync(rcFile, 'utf8'); 188 | if (!rcFileContent.includes(binDir)) { 189 | fs.appendFileSync(rcFile, exportCommand, 'utf8'); 190 | log(`Added Bento4 'bin' directory to PATH in ${rcFile}.`); 191 | } else { 192 | log(`'${binDir}' is already in PATH within ${rcFile}.`); 193 | } 194 | } else { 195 | fs.writeFileSync(rcFile, exportCommand, 'utf8'); 196 | log(`Created ${rcFile} and added Bento4 'bin' directory to PATH.`); 197 | } 198 | } 199 | } 200 | 201 | 202 | async function downloadFile(url, destPath, log, mainWin) { 203 | log(`Starting download from ${url}`); 204 | const writer = fs.createWriteStream(destPath); 205 | 206 | const response = await axios({ 207 | method: 'GET', 208 | url: url, 209 | responseType: 'stream', 210 | }); 211 | 212 | const totalLength = parseInt(response.headers['content-length'], 10); 213 | let downloaded = 0; 214 | 215 | response.data.on('data', (chunk) => { 216 | downloaded += chunk.length; 217 | 218 | const progress = Math.floor((downloaded / totalLength) * 100); 219 | 220 | if (mainWin && typeof mainWin.webContents.send === 'function') { 221 | mainWin.webContents.send('installation-progress', { 222 | percent: Math.floor(progress * 0.4), 223 | status: `Downloading Bento4: ${progress}%` 224 | }); 225 | } 226 | }); 227 | 228 | response.data.pipe(writer); 229 | return new Promise((resolve, reject) => { 230 | writer.on('finish', () => { 231 | log('Download completed.'); 232 | resolve(); 233 | }); 234 | writer.on('error', (err) => { 235 | reject(err); 236 | }); 237 | }); 238 | } 239 | 240 | async function extractZip(zipPath, extractTo, log, mainWin) { 241 | log(`Extracting ${zipPath} to ${extractTo}`); 242 | 243 | if (mainWin && typeof mainWin.webContents.send === 'function') { 244 | mainWin.webContents.send('installation-progress', { 245 | percent: 40, 246 | status: 'Extracting Bento4...' 247 | }); 248 | } 249 | 250 | let extractionProgress = 0; 251 | const updateInterval = setInterval(() => { 252 | extractionProgress += 5; 253 | if (extractionProgress <= 35) { 254 | if (mainWin && typeof mainWin.webContents.send === 'function') { 255 | mainWin.webContents.send('installation-progress', { 256 | percent: 40 + extractionProgress, 257 | status: `Extracting Bento4: ${Math.min(100, Math.floor(extractionProgress * 100 / 35))}%` 258 | }); 259 | } 260 | } 261 | }, 500); 262 | 263 | await fs.createReadStream(zipPath) 264 | .pipe(unzipper.Extract({ path: extractTo })) 265 | .promise(); 266 | 267 | clearInterval(updateInterval); 268 | 269 | if (mainWin && typeof mainWin.webContents.send === 'function') { 270 | mainWin.webContents.send('installation-progress', { 271 | percent: 75, 272 | status: 'Extraction completed' 273 | }); 274 | } 275 | 276 | log(`Extraction completed to ${extractTo}`); 277 | } 278 | 279 | async function downloadAndInstallBento4(mainWin, options = {}) { 280 | const log = createLogger(mainWin); 281 | 282 | try { 283 | log('Starting Bento4 installation...'); 284 | const platform = os.platform(); 285 | const arch = os.arch(); 286 | const version = options.version || config.version; 287 | const { downloadUrl, defaultInstallDir } = getDownloadInfo(platform, arch, version); 288 | const installDir = options.installDir || config.installDir || defaultInstallDir; 289 | 290 | log(`Detected platform: ${platform}`); 291 | log(`Architecture: ${arch}`); 292 | log(`Bento4 version: ${version}`); 293 | log(`Download URL: ${downloadUrl}`); 294 | log(`Installation directory: ${installDir}`); 295 | 296 | if (!fs.existsSync(installDir)) { 297 | fs.mkdirSync(installDir, { recursive: true }); 298 | log(`Created installation directory at ${installDir}`); 299 | } else { 300 | log(`Installation directory exists at ${installDir}`); 301 | } 302 | if (mainWin && typeof mainWin.webContents.send === 'function') { 303 | mainWin.webContents.send('installation-progress', { 304 | percent: 0, 305 | status: 'Starting Bento4 installation...' 306 | }); 307 | } 308 | 309 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bento4-')); 310 | const zipPath = path.join(tempDir, 'Bento4-SDK.zip'); 311 | 312 | await downloadFile(downloadUrl, zipPath, log, mainWin); 313 | 314 | await extractZip(zipPath, tempDir, log, mainWin); 315 | 316 | function findBinDir(startPath) { 317 | if (!fs.existsSync(startPath)) return null; 318 | 319 | const items = fs.readdirSync(startPath); 320 | 321 | if (items.includes('bin')) { 322 | return path.join(startPath, 'bin'); 323 | } 324 | 325 | for (const item of items) { 326 | const itemPath = path.join(startPath, item); 327 | if (fs.statSync(itemPath).isDirectory()) { 328 | const binPath = findBinDir(itemPath); 329 | if (binPath) return binPath; 330 | } 331 | } 332 | 333 | return null; 334 | } 335 | 336 | const binDir = findBinDir(tempDir); 337 | 338 | if (!binDir) { 339 | throw new Error('Bento4 bin directory not found after extraction'); 340 | } 341 | 342 | if (!fs.existsSync(installDir)) { 343 | fs.mkdirSync(installDir, { recursive: true }); 344 | } 345 | 346 | const sdkDir = path.dirname(binDir); 347 | const finalBinDir = path.join(installDir, 'bin'); 348 | 349 | if (fs.existsSync(finalBinDir)) { 350 | fs.rmSync(finalBinDir, { recursive: true, force: true }); 351 | } 352 | 353 | fs.cpSync(binDir, finalBinDir, { recursive: true }); 354 | 355 | if (platform === 'darwin') { 356 | log('Setting up permissions for macOS...'); 357 | fs.chmodSync(finalBinDir, '755'); 358 | log('Set permissions for bin directory'); 359 | } 360 | 361 | log('Adding Bento4 to system PATH...'); 362 | if (mainWin && typeof mainWin.webContents.send === 'function') { 363 | mainWin.webContents.send('installation-progress', { 364 | percent: 80, 365 | status: 'Configuring system PATH...' 366 | }); 367 | } 368 | 369 | await addBento4ToPath(finalBinDir, platform, log, mainWin); 370 | 371 | fs.rmSync(tempDir, { recursive: true, force: true }); 372 | log('Cleaned up temporary files.'); 373 | 374 | if (mainWin && typeof mainWin.webContents.send === 'function') { 375 | mainWin.webContents.send('installation-progress', { 376 | percent: 100, 377 | status: 'Bento4 installed successfully! Please restart your terminal to use Bento4.' 378 | }); 379 | } 380 | 381 | log('Bento4 has been successfully downloaded and installed. Please restart your terminal or system for the PATH changes to take effect.'); 382 | } catch (error) { 383 | log(`Installation failed: ${error.message}`); 384 | if (error.stack) { 385 | log(`Stack trace: ${error.stack}`); 386 | } 387 | if (mainWin && typeof mainWin.webContents.send === 'function') { 388 | mainWin.webContents.send('installation-error', error.message); 389 | } 390 | throw error; 391 | } 392 | } 393 | 394 | module.exports = { downloadAndInstallBento4 }; -------------------------------------------------------------------------------- /src/funcs/installers/ffmpegInstaller.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const fs = require('fs'); 4 | const axios = require('axios'); 5 | const decompress = require('decompress'); 6 | const decompressUnzip = require('decompress-unzip'); 7 | const { spawn, execSync } = require('child_process'); 8 | const xml2js = require('xml2js'); 9 | 10 | // Add the extractTarXz function from one.js 11 | async function extractTarXz(filePath, destination) { 12 | return new Promise((resolve, reject) => { 13 | console.log(`Extracting ${filePath} to ${destination}`); 14 | const tarProcess = spawn('tar', ['-xJf', filePath, '-C', destination]); 15 | 16 | let stderr = ''; 17 | 18 | tarProcess.stderr.on('data', (data) => { 19 | stderr += data.toString(); 20 | console.log('Tar stderr:', stderr); 21 | }); 22 | 23 | tarProcess.on('close', (code) => { 24 | console.log(`Tar process exited with code ${code}`); 25 | if (code === 0) { 26 | resolve(); 27 | } else { 28 | reject(new Error(`Tar extraction failed: ${stderr}`)); 29 | } 30 | }); 31 | }); 32 | } 33 | 34 | async function getLatestFFmpegVersion() { 35 | try { 36 | const response = await axios.get('https://evermeet.cx/ffmpeg/rss.xml'); 37 | const parser = new xml2js.Parser(); 38 | const result = await parser.parseStringPromise(response.data); 39 | const latestItem = result.rss.channel[0].item[0]; 40 | return { 41 | version: latestItem.title[0].replace('.zip', ''), 42 | url: latestItem.link[0] 43 | }; 44 | } catch (error) { 45 | throw new Error('Failed to fetch latest FFmpeg version'); 46 | } 47 | } 48 | 49 | async function getLatestFFprobeVersion() { 50 | try { 51 | const response = await axios.get('https://evermeet.cx/ffmpeg/ffprobe-rss.xml'); 52 | const parser = new xml2js.Parser(); 53 | const result = await parser.parseStringPromise(response.data); 54 | const latestItem = result.rss.channel[0].item[0]; 55 | return { 56 | version: latestItem.title[0].replace('.zip', ''), 57 | url: latestItem.link[0] 58 | }; 59 | } catch (error) { 60 | throw new Error('Failed to fetch latest FFprobe version'); 61 | } 62 | } 63 | 64 | async function getFileSize(url) { 65 | try { 66 | const headResponse = await axios.head(url); 67 | const contentLength = headResponse.headers['content-length']; 68 | return contentLength ? parseInt(contentLength, 10) : null; 69 | } catch (error) { 70 | console.warn('HEAD request failed, cannot determine file size:', error.message); 71 | return null; 72 | } 73 | } 74 | 75 | // Update system PATH permanently (platform-specific) 76 | async function updateSystemPath(ffmpegPath, mainWin) { 77 | const platform = os.platform(); 78 | 79 | // Update current process.env.PATH (session only) 80 | process.env.PATH = `${ffmpegPath}${path.delimiter}${process.env.PATH}`; 81 | console.log(`Updated current process PATH: ${process.env.PATH}`); 82 | 83 | try { 84 | if (platform === 'win32') { 85 | // Windows: update using PowerShell 86 | const currentPath = execSync('powershell -command "[Environment]::GetEnvironmentVariable(\'Path\', \'User\')"').toString().trim(); 87 | if (!currentPath.includes(ffmpegPath)) { 88 | const newPath = `${ffmpegPath}${path.delimiter}${currentPath}`; 89 | execSync(`powershell -command "[Environment]::SetEnvironmentVariable('Path', '${newPath}', 'User')"`); 90 | console.log('Updated Windows User PATH environment variable'); 91 | } 92 | } else { 93 | // For macOS and Linux update the shell profile(s) 94 | const homeDir = os.homedir(); 95 | // For macOS using zsh, update both .zshrc and .zprofile 96 | const shellConfigs = ['.zshrc', '.zprofile', '.bashrc', '.bash_profile', '.profile']; 97 | const exportLine = `\n# FFmpeg PATH\nexport PATH="${ffmpegPath}:$PATH"\n`; 98 | 99 | shellConfigs.forEach(configFile => { 100 | const configPath = path.join(homeDir, configFile); 101 | if (fs.existsSync(configPath)) { 102 | const content = fs.readFileSync(configPath, 'utf8'); 103 | if (!content.includes(ffmpegPath)) { 104 | fs.appendFileSync(configPath, exportLine); 105 | console.log(`Updated ${configFile} with FFmpeg PATH`); 106 | } 107 | } else { 108 | // If the file does not exist, create it with our export line. 109 | fs.writeFileSync(configPath, exportLine); 110 | console.log(`Created ${configFile} with FFmpeg PATH`); 111 | } 112 | }); 113 | } 114 | 115 | return true; 116 | } catch (error) { 117 | console.error('Failed to update system PATH:', error); 118 | mainWin.webContents.send('installation-progress', { 119 | percent: 85, 120 | status: `PATH update issue: ${error.message}. FFmpeg will work after restart.` 121 | }); 122 | return false; 123 | } 124 | } 125 | 126 | // Test if FFmpeg is accessible with the current PATH 127 | function testFFmpegAccess(ffmpegDir) { 128 | try { 129 | // Clone current process.env and add ffmpegDir to PATH 130 | const env = { ...process.env }; 131 | env.PATH = `${ffmpegDir}${path.delimiter}${env.PATH}`; 132 | 133 | // Try to run ffmpeg -version with the updated PATH 134 | execSync('ffmpeg -version', { env, stdio: 'pipe' }); 135 | return true; 136 | } catch (error) { 137 | console.log('FFmpeg not accessible via PATH:', error.message); 138 | return false; 139 | } 140 | } 141 | 142 | async function downloadAndInstallFFmpeg(mainWin) { 143 | try { 144 | const platform = os.platform(); 145 | let downloadUrl; 146 | 147 | if (platform === 'win32') { 148 | downloadUrl = 'https://www.gyan.dev/ffmpeg/builds/ffmpeg-release-essentials.zip'; 149 | } else if (platform === 'linux') { 150 | downloadUrl = 'https://johnvansickle.com/ffmpeg/builds/ffmpeg-git-amd64-static.tar.xz'; 151 | } 152 | 153 | // Send initial progress update 154 | mainWin.webContents.send('installation-progress', { 155 | percent: 0, 156 | status: 'Starting FFmpeg download...' 157 | }); 158 | 159 | // Define installation directory (for all platforms) 160 | const homeDir = os.homedir(); 161 | let ffmpegDir; 162 | 163 | if (platform === 'win32') { 164 | ffmpegDir = path.join(homeDir, 'ffmpeg', 'bin'); 165 | } else { 166 | // macOS/Linux: Use ~/.local/bin which is often in PATH 167 | ffmpegDir = path.join(homeDir, '.local', 'bin'); 168 | } 169 | 170 | console.log('Creating directory:', ffmpegDir); 171 | fs.mkdirSync(ffmpegDir, { recursive: true }); 172 | 173 | if (platform === 'darwin') { 174 | // Download and extract FFmpeg binary 175 | const ffmpegLatest = await getLatestFFmpegVersion(); 176 | const ffmpegDownloadUrl = ffmpegLatest.url; 177 | console.log('FFmpeg download URL:', ffmpegDownloadUrl); 178 | 179 | // Download FFmpeg 180 | const ffmpegTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ffmpeg-')); 181 | const ffmpegFileName = path.basename(ffmpegDownloadUrl); 182 | const ffmpegTempFilePath = path.join(ffmpegTempDir, ffmpegFileName); 183 | 184 | const totalLength = await getFileSize(ffmpegDownloadUrl); 185 | let downloadedLength = 0; 186 | 187 | const ffmpegWriter = fs.createWriteStream(ffmpegTempFilePath); 188 | const ffmpegResponse = await axios({ 189 | method: 'GET', 190 | url: ffmpegDownloadUrl, 191 | responseType: 'stream' 192 | }); 193 | 194 | // Listen for data events to track progress 195 | ffmpegResponse.data.on('data', (chunk) => { 196 | downloadedLength += chunk.length; 197 | // Map the fraction of download completion into the 0-30% range 198 | const downloadPercent = Math.round((downloadedLength / totalLength) * 30); 199 | mainWin.webContents.send('installation-progress', { 200 | percent: downloadPercent, 201 | status: `Downloading FFmpeg... ${downloadPercent}%` 202 | }); 203 | }); 204 | 205 | ffmpegResponse.data.pipe(ffmpegWriter); 206 | await new Promise((resolve, reject) => { 207 | ffmpegWriter.on('finish', resolve); 208 | ffmpegWriter.on('error', reject); 209 | }); 210 | 211 | mainWin.webContents.send('installation-progress', { 212 | percent: 30, 213 | status: 'Extracting FFmpeg...' 214 | }); 215 | 216 | await decompress(ffmpegTempFilePath, ffmpegDir, { 217 | plugins: [decompressUnzip()] 218 | }); 219 | const ffmpegPath = path.join(ffmpegDir, 'ffmpeg'); 220 | if (!fs.existsSync(ffmpegPath)) { 221 | throw new Error('FFmpeg binary not found after extraction'); 222 | } 223 | fs.chmodSync(ffmpegPath, 0o755); 224 | fs.rmSync(ffmpegTempDir, { recursive: true, force: true }); 225 | 226 | // Download and extract FFprobe binary 227 | const ffprobeLatest = await getLatestFFprobeVersion(); 228 | const ffprobeDownloadUrl = ffprobeLatest.url; 229 | console.log('FFprobe download URL:', ffprobeDownloadUrl); 230 | 231 | const ffprobeTempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ffprobe-')); 232 | const ffprobeFileName = path.basename(ffprobeDownloadUrl); 233 | const ffprobeTempFilePath = path.join(ffprobeTempDir, ffprobeFileName); 234 | 235 | const ffprobeTotalLength = await getFileSize(ffprobeDownloadUrl); 236 | let ffprobeDownloadedLength = 0; 237 | 238 | const ffprobeWriter = fs.createWriteStream(ffprobeTempFilePath); 239 | const ffprobeResponse = await axios({ 240 | method: 'GET', 241 | url: ffprobeDownloadUrl, 242 | responseType: 'stream' 243 | }); 244 | 245 | ffprobeResponse.data.on('data', (chunk) => { 246 | ffprobeDownloadedLength += chunk.length; 247 | // Map the fraction of download completion into the 30-50% range. 248 | let base = 30; // start progress for FFprobe download 249 | const downloadPercent = base + Math.round((ffprobeDownloadedLength / ffprobeTotalLength) * 20); 250 | mainWin.webContents.send('installation-progress', { 251 | percent: downloadPercent, 252 | status: `Downloading FFprobe... ${downloadPercent}%` 253 | }); 254 | }); 255 | 256 | ffprobeResponse.data.pipe(ffprobeWriter); 257 | await new Promise((resolve, reject) => { 258 | ffprobeWriter.on('finish', resolve); 259 | ffprobeWriter.on('error', reject); 260 | }); 261 | 262 | mainWin.webContents.send('installation-progress', { 263 | percent: 50, 264 | status: 'Extracting FFprobe...' 265 | }); 266 | 267 | await decompress(ffprobeTempFilePath, ffmpegDir, { 268 | plugins: [decompressUnzip()] 269 | }); 270 | const ffprobePath = path.join(ffmpegDir, 'ffprobe'); 271 | if (!fs.existsSync(ffprobePath)) { 272 | throw new Error('FFprobe binary not found after extraction'); 273 | } 274 | fs.chmodSync(ffprobePath, 0o755); 275 | fs.rmSync(ffprobeTempDir, { recursive: true, force: true }); 276 | 277 | } else if (platform === 'win32' || platform === 'linux') { 278 | // Common download code for Windows and Linux 279 | const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ffmpeg-')); 280 | const fileName = path.basename(downloadUrl); 281 | const tempFilePath = path.join(tempDir, fileName); 282 | 283 | // Download file 284 | const totalLength = await getFileSize(downloadUrl); 285 | let downloadedLength = 0; 286 | 287 | const writer = fs.createWriteStream(tempFilePath); 288 | const response = await axios({ 289 | method: 'GET', 290 | url: downloadUrl, 291 | responseType: 'stream' 292 | }); 293 | 294 | // Listen for data events to track progress 295 | response.data.on('data', (chunk) => { 296 | downloadedLength += chunk.length; 297 | const downloadPercent = Math.round((downloadedLength / totalLength) * 30); 298 | mainWin.webContents.send('installation-progress', { 299 | percent: downloadPercent, 300 | status: `Downloading FFmpeg... ${downloadPercent}%` 301 | }); 302 | }); 303 | 304 | response.data.pipe(writer); 305 | await new Promise((resolve, reject) => { 306 | writer.on('finish', resolve); 307 | writer.on('error', reject); 308 | }); 309 | 310 | mainWin.webContents.send('installation-progress', { 311 | percent: 30, 312 | status: 'Extracting FFmpeg...' 313 | }); 314 | 315 | if (platform === 'win32') { 316 | await decompress(tempFilePath, tempDir, { 317 | plugins: [decompressUnzip()] 318 | }); 319 | 320 | // Windows FFmpeg zip has a nested directory structure 321 | const extractedFiles = fs.readdirSync(tempDir); 322 | console.log('Extracted files:', extractedFiles); 323 | 324 | // Find the bin directory that contains ffmpeg.exe 325 | let binDir = null; 326 | for (const file of extractedFiles) { 327 | const fullPath = path.join(tempDir, file); 328 | if (fs.statSync(fullPath).isDirectory()) { 329 | // Look for nested bin directory 330 | const nestedDirs = fs.readdirSync(fullPath); 331 | const hasBin = nestedDirs.includes('bin'); 332 | if (hasBin) { 333 | binDir = path.join(fullPath, 'bin'); 334 | break; 335 | } 336 | } 337 | } 338 | 339 | if (!binDir) { 340 | throw new Error('Could not find bin directory in extracted FFmpeg'); 341 | } 342 | 343 | // Copy the executables 344 | const exes = ['ffmpeg.exe', 'ffprobe.exe']; 345 | for (const exe of exes) { 346 | const sourcePath = path.join(binDir, exe); 347 | const targetPath = path.join(ffmpegDir, exe); 348 | if (fs.existsSync(sourcePath)) { 349 | fs.copyFileSync(sourcePath, targetPath); 350 | console.log(`Copied ${exe} to ${targetPath}`); 351 | } else { 352 | console.warn(`${exe} not found in extracted files`); 353 | } 354 | } 355 | } else if (platform === 'linux') { 356 | console.log('Extracting on Linux...'); 357 | await extractTarXz(tempFilePath, tempDir); 358 | 359 | // Find the extracted directory 360 | const extractedFiles = fs.readdirSync(tempDir); 361 | console.log('Extracted files:', extractedFiles); 362 | 363 | // Find the ffmpeg directory (it usually contains 'ffmpeg' in the name) 364 | const ffmpegFolder = extractedFiles.find(f => f.includes('ffmpeg')); 365 | if (!ffmpegFolder) { 366 | throw new Error('FFmpeg folder not found after extraction'); 367 | } 368 | 369 | const extractedDir = path.join(tempDir, ffmpegFolder); 370 | console.log('Extracted directory:', extractedDir); 371 | 372 | // Copy FFmpeg executables to bin directory 373 | const binaries = ['ffmpeg', 'ffprobe']; 374 | for (const binary of binaries) { 375 | const sourcePath = path.join(extractedDir, binary); 376 | const targetPath = path.join(ffmpegDir, binary); 377 | 378 | console.log(`Copying ${sourcePath} to ${targetPath}`); 379 | fs.copyFileSync(sourcePath, targetPath); 380 | fs.chmodSync(targetPath, 0o755); // Ensure executable permissions 381 | console.log(`Set permissions for ${targetPath}`); 382 | } 383 | } 384 | 385 | // Clean up temp directory 386 | fs.rmSync(tempDir, { recursive: true, force: true }); 387 | } 388 | 389 | // Update PATH to include FFmpeg directory 390 | mainWin.webContents.send('installation-progress', { 391 | percent: 80, 392 | status: 'Updating system PATH...' 393 | }); 394 | 395 | await updateSystemPath(ffmpegDir, mainWin); 396 | 397 | // Verify the installation using the updated PATH 398 | console.log('Verifying installation...'); 399 | const ffmpegAccessible = testFFmpegAccess(ffmpegDir); 400 | 401 | if (!ffmpegAccessible) { 402 | console.log('FFmpeg not immediately accessible, but was installed to:', ffmpegDir); 403 | mainWin.webContents.send('installation-progress', { 404 | percent: 95, 405 | status: 'FFmpeg installed but not in current PATH. It will be available after terminal restart.' 406 | }); 407 | } else { 408 | console.log('FFmpeg successfully installed and accessible via PATH!'); 409 | } 410 | 411 | mainWin.webContents.send('installation-progress', { 412 | percent: 100, 413 | status: 'FFmpeg installed successfully! Please restart your terminal if FFmpeg commands are not working.' 414 | }); 415 | 416 | } catch (error) { 417 | mainWin.webContents.send('installation-error', error.message); 418 | console.error('Installation failed:', error); 419 | } 420 | } 421 | 422 | function formatBytes(bytes, decimals = 2) { 423 | if (bytes === 0) return '0 Bytes'; 424 | const k = 1024, 425 | dm = decimals < 0 ? 0 : decimals, 426 | sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'], 427 | i = Math.floor(Math.log(bytes) / Math.log(k)); 428 | return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; 429 | } 430 | 431 | module.exports = { downloadAndInstallFFmpeg }; -------------------------------------------------------------------------------- /src/funcs/installers/gitInstaller.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | const fs = require('fs'); 3 | const path = require('path'); 4 | const { exec } = require('child_process'); 5 | const os = require('os'); 6 | const axios = require('axios'); 7 | 8 | async function downloadAndInstallGit(win) { 9 | const platform = os.platform(); 10 | const tempDir = os.tmpdir(); 11 | let installerPath; 12 | let downloadUrl; 13 | 14 | try { 15 | if (platform === 'win32') { 16 | downloadUrl = 'https://github.com/git-for-windows/git/releases/download/v2.47.0.windows.2/Git-2.47.0.2-64-bit.exe'; 17 | installerPath = path.join(tempDir, 'git-installer.exe'); 18 | 19 | // Download with progress tracking 20 | win.webContents.send('installation-progress', { 21 | percent: 0, 22 | status: 'Starting Git download...' 23 | }); 24 | 25 | const writer = fs.createWriteStream(installerPath); 26 | const response = await axios({ 27 | method: 'GET', 28 | url: downloadUrl, 29 | responseType: 'stream', 30 | }); 31 | 32 | const totalLength = response.headers['content-length']; 33 | let downloaded = 0; 34 | 35 | response.data.on('data', (chunk) => { 36 | downloaded += chunk.length; 37 | const progress = Math.floor((downloaded / totalLength) * 100); 38 | win.webContents.send('installation-progress', { 39 | percent: Math.floor(progress * 0.4), // 40% of total progress 40 | status: `Downloading Git: ${progress}%` 41 | }); 42 | }); 43 | 44 | response.data.pipe(writer); 45 | await new Promise((resolve, reject) => { 46 | writer.on('finish', resolve); 47 | writer.on('error', reject); 48 | }); 49 | 50 | // Installation 51 | win.webContents.send('installation-progress', { 52 | percent: 50, 53 | status: 'Installing Git...' 54 | }); 55 | 56 | await executeCommand(`"${installerPath}" /VERYSILENT /NORESTART /NOCANCEL /SP- /CLOSEAPPLICATIONS /RESTARTAPPLICATIONS`); 57 | 58 | } else if (platform === 'darwin') { 59 | win.webContents.send('installation-progress', { 60 | percent: 0, 61 | status: 'Checking Homebrew installation...' 62 | }); 63 | 64 | try { 65 | await executeCommand('brew --version'); 66 | 67 | win.webContents.send('installation-progress', { 68 | percent: 20, 69 | status: 'Updating Homebrew...' 70 | }); 71 | 72 | await executeCommand('brew update'); 73 | 74 | win.webContents.send('installation-progress', { 75 | percent: 50, 76 | status: 'Installing Git via Homebrew...' 77 | }); 78 | 79 | await executeCommand('brew install git'); 80 | } catch (error) { 81 | win.webContents.send('installation-progress', { 82 | percent: 20, 83 | status: 'Installing Homebrew...' 84 | }); 85 | 86 | const brewInstallCommand = '/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"'; 87 | await executeCommand(brewInstallCommand); 88 | 89 | win.webContents.send('installation-progress', { 90 | percent: 50, 91 | status: 'Installing Git via Homebrew...' 92 | }); 93 | 94 | await executeCommand('brew install git'); 95 | } 96 | 97 | } else if (platform === 'linux') { 98 | const osRelease = fs.readFileSync('/etc/os-release', 'utf8'); 99 | const isDebian = osRelease.includes('ID=debian') || osRelease.includes('ID=ubuntu'); 100 | const isRedHat = osRelease.includes('ID=rhel') || osRelease.includes('ID=fedora') || osRelease.includes('ID=centos'); 101 | 102 | win.webContents.send('installation-progress', { 103 | percent: 20, 104 | status: 'Updating package manager...' 105 | }); 106 | 107 | if (isDebian) { 108 | await executeCommand('sudo apt-get update'); 109 | 110 | win.webContents.send('installation-progress', { 111 | percent: 50, 112 | status: 'Installing Git...' 113 | }); 114 | 115 | await executeCommand('sudo apt-get install -y git'); 116 | } else if (isRedHat) { 117 | const hasDnf = await executeCommand('which dnf').catch(() => false); 118 | 119 | win.webContents.send('installation-progress', { 120 | percent: 50, 121 | status: 'Installing Git...' 122 | }); 123 | 124 | if (hasDnf) { 125 | await executeCommand('sudo dnf install -y git'); 126 | } else { 127 | await executeCommand('sudo yum install -y git'); 128 | } 129 | } else { 130 | throw new Error('Unsupported Linux distribution'); 131 | } 132 | } 133 | 134 | // Verify installation 135 | win.webContents.send('installation-progress', { 136 | percent: 90, 137 | status: 'Verifying installation...' 138 | }); 139 | 140 | const version = await executeCommand('git --version'); 141 | 142 | win.webContents.send('installation-progress', { 143 | percent: 100, 144 | status: `Git ${version} installed successfully` 145 | }); 146 | 147 | } catch (error) { 148 | win.webContents.send('installation-error', error.message); 149 | throw error; 150 | } finally { 151 | // Cleanup 152 | if (installerPath && fs.existsSync(installerPath)) { 153 | fs.unlinkSync(installerPath); 154 | } 155 | } 156 | } 157 | 158 | function executeCommand(command) { 159 | return new Promise((resolve, reject) => { 160 | exec(command, (error, stdout, stderr) => { 161 | if (error) { 162 | reject(new Error(`Command failed: ${error.message}\n${stderr}`)); 163 | } else { 164 | resolve(stdout.trim()); 165 | } 166 | }); 167 | }); 168 | } 169 | 170 | module.exports = { downloadAndInstallGit }; -------------------------------------------------------------------------------- /src/funcs/spawner.js: -------------------------------------------------------------------------------- 1 | const { shell, app} = require('electron'); 2 | const { exec } = require('child_process'); 3 | const { spawn } = require('child_process'); 4 | const path = require("path"); 5 | function getResourcePath(filename) { 6 | if (app.isPackaged) { 7 | return path.join(process.resourcesPath, 'app.asar.unpacked', 'src', filename) 8 | .replace(/\\/g, '/'); 9 | } 10 | return path.join(__dirname, filename).replace(/\\/g, '/'); 11 | } 12 | 13 | 14 | function getPythonCommand() { 15 | switch (process.platform) { 16 | case 'win32': 17 | return 'py'; 18 | case 'darwin': 19 | case 'linux': 20 | // fall back to python if python3 not found 21 | return new Promise((resolve) => { 22 | exec('python3 --version', (error) => { 23 | if (error) { 24 | resolve('python'); 25 | } else { 26 | resolve('python3'); 27 | } 28 | }); 29 | }); 30 | default: 31 | return 'python'; 32 | } 33 | } 34 | 35 | module.exports = {getPythonCommand}; -------------------------------------------------------------------------------- /src/funcs/updatechecker.js: -------------------------------------------------------------------------------- 1 | const { app, dialog, shell } = require('electron'); 2 | const https = require('https'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const { exec } = require('child_process'); 6 | 7 | module.exports = class UpdateChecker { 8 | constructor(owner, repo, currentVersion) { 9 | this.owner = owner; 10 | this.repo = repo; 11 | this.currentVersion = currentVersion; 12 | this.apiUrl = `https://api.github.com/repos/${owner}/${repo}/releases`; 13 | } 14 | 15 | async checkForUpdates(autoUpdate = false) { 16 | try { 17 | const latestRelease = await this.getLatestRelease(); 18 | 19 | if (!latestRelease) { 20 | console.log('No releases found'); 21 | return; 22 | } 23 | 24 | const latestVersion = latestRelease.tag_name.replace('v', ''); 25 | const currentVersion = this.currentVersion.replace('v', ''); 26 | 27 | if (this.compareVersions(latestVersion, currentVersion) > 0) { 28 | if (autoUpdate) { 29 | console.log('Auto-update enabled. Downloading and installing update...'); 30 | await this.downloadAndInstall(latestRelease); 31 | } else { 32 | await this.showUpdateDialog(latestRelease); 33 | } 34 | } else { 35 | console.log('Application is up to date'); 36 | } 37 | } catch (error) { 38 | console.error('Error checking for updates:', error); 39 | } 40 | } 41 | 42 | getLatestRelease() { 43 | return new Promise((resolve, reject) => { 44 | const options = { 45 | headers: { 46 | 'User-Agent': 'electron-app' 47 | } 48 | }; 49 | 50 | https.get(this.apiUrl, options, (res) => { 51 | let data = ''; 52 | 53 | res.on('data', (chunk) => { 54 | data += chunk; 55 | }); 56 | 57 | res.on('end', () => { 58 | try { 59 | const releases = JSON.parse(data); 60 | const stableRelease = releases.find(release => !release.prerelease); 61 | resolve(stableRelease); 62 | } catch (error) { 63 | reject(error); 64 | } 65 | }); 66 | }).on('error', reject); 67 | }); 68 | } 69 | 70 | compareVersions(v1, v2) { 71 | const v1Parts = v1.split('.').map(Number); 72 | const v2Parts = v2.split('.').map(Number); 73 | 74 | for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) { 75 | const v1Part = v1Parts[i] || 0; 76 | const v2Part = v2Parts[i] || 0; 77 | if (v1Part > v2Part) return 1; 78 | if (v1Part < v2Part) return -1; 79 | } 80 | return 0; 81 | } 82 | 83 | async showUpdateDialog(release) { 84 | const response = await dialog.showMessageBox({ 85 | type: 'info', 86 | title: 'Update Available', 87 | message: `A new version (${release.tag_name}) is available!`, 88 | detail: `Release notes:\n${release.body || 'No release notes available.'}\n\nWould you like to download it?`, 89 | buttons: ['Download', 'Later'], 90 | defaultId: 0 91 | }); 92 | 93 | if (response.response === 0) { 94 | await shell.openExternal(release.html_url); 95 | } 96 | } 97 | 98 | async downloadAndInstall(release) { 99 | const asset = release.assets.find(a => a.name.endsWith('.exe') || a.name.endsWith('.dmg') || a.name.endsWith('.AppImage')); 100 | 101 | if (!asset) { 102 | console.error('No compatible update file found.'); 103 | return; 104 | } 105 | 106 | const downloadPath = path.join(app.getPath('temp'), asset.name); 107 | 108 | const file = fs.createWriteStream(downloadPath); 109 | https.get(asset.browser_download_url, (response) => { 110 | response.pipe(file); 111 | 112 | file.on('finish', () => { 113 | file.close(() => { 114 | console.log('Update downloaded. Installing now...'); 115 | if (process.platform === 'darwin' || process.platform === 'linux') { 116 | exec(`open "${downloadPath}"`); 117 | } else if (process.platform === 'win32') { 118 | exec(`start "" "${downloadPath}"`); 119 | } 120 | app.quit(); 121 | }); 122 | }); 123 | }).on('error', (err) => { 124 | fs.unlink(downloadPath, () => console.error('Download failed:', err)); 125 | }); 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /src/pages/downloads.html: -------------------------------------------------------------------------------- 1 | <!DOCTYPE html> 2 | <html lang="en"> 3 | <head> 4 | <meta charset="UTF-8"> 5 | <title>Download History 6 | 7 | 8 |

If you see this: Something went wrong. Please change database file path and don't forget to add .db extension while selection. And restart app.

9 |
10 | 11 |
12 |
13 | 16 |
17 | 18 | 20 | 21 | -------------------------------------------------------------------------------- /src/pages/firststart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Media Harbor Setup 6 | 7 | 8 | 9 |
10 |
11 |

Welcome to Media Harbor

12 |

Let's set up your environment for the best experience.

13 |
14 | 15 |
16 |
17 | 18 |
19 |

Required Dependencies

20 |
21 |
22 |

Required Dependencies

23 |
24 | ⭕ 25 | Python (3.9-3.12) 26 | 27 |
28 |
29 | ⭕ 30 | Git 31 | 32 |
33 |
34 | ⭕ 35 | FFmpeg 36 | 37 |
38 |
39 | ⭕ 40 | yt-dlp 41 | 42 |
43 |
44 | 45 |
46 |

Optional Dependencies

47 |
48 | ⭕ 49 | YouTube Music (ytmusicapi) 50 | 51 |
52 |
53 | ⭕ 54 | Qobuz 55 | 56 |
57 |
58 | ⭕ 59 | Deezer 60 | 61 |
62 |
63 | ⭕ 64 | Tidal 65 | 66 |
67 |
68 | ⭕ 69 | Apple Music 70 | 71 |
72 |
73 | ⭕ 74 | Spotify 75 | 76 |
77 |
78 |
79 |

Having trouble with automatic installation? Use our detailed manual guide:

80 | 81 |
82 |
83 |
84 |
85 |

Installing Dependencies

86 |
87 |
88 |
89 |

Please wait...

90 |
91 |
92 |
93 | 94 | 95 |
96 |
97 | 98 |
99 |

Setup Complete!

100 |

Don't forget to enter your credentials in the settings after launch.

101 |
102 | 103 |
104 |
105 |
106 |
107 |
108 |

Manual Installation Guide

109 |

If you're experiencing issues with automatic installation, follow the steps below to install components manually.

110 | 111 |
112 |

Required Components

113 |
114 |
115 |
Component
116 |
Source
117 |
Installation
118 |
119 | 120 |
121 |
Python (3.9 - 3.12)
122 |
python.org
123 |
Download and install from official website
124 |
125 | 126 |
127 |
FFmpeg
128 |
ffmpeg.org
129 |
Follow installation instructions on official website
130 |
131 | 132 |
133 |
Git
134 |
git-scm.com
135 |
Download and install from official website
136 |
137 | 138 |
139 |
yt-dlp (YouTube)
140 |
yt-dlp GitHub
141 |
pip install yt-dlp
142 |
143 |
144 |
145 | 146 |
147 |

Optional Components

148 |
149 |
150 |
Component
151 |
Source
152 |
Installation
153 |
154 | 155 |
156 |
youtubemusicapi (YTMusic)
157 |
youtubemusicapi GitHub
158 |
pip install youtubemusicapi
159 |
160 | 161 |
162 |
custom_streamrip (Qobuz, Deezer, Tidal)
163 |
custom_streamrip GitHub
164 |
pip install git+https://github.com/mediaharbor/custom_streamrip.git
165 |
166 | 167 |
168 |
custom_votify (Spotify)
169 |
custom_votify GitHub
170 |
pip install git+https://github.com/mediaharbor/custom_votify.git
171 |
172 | 173 |
174 |
custom_gamdl (Apple Music)
175 |
custom_gamdl GitHub
176 |
pip install git+https://github.com/mediaharbor/custom_gamdl.git
177 |
178 | 179 |
180 |
pyapplemusicapi (Apple Music Search)
181 |
pyapplemusicapi GitHub
182 |
pip install pyapplemusicapi
183 |
184 | 185 |
186 |
Bento4 (MP4Decrypt)
187 |
Bento4 Binaries
188 |
Download and extract, then add to system path
189 |
190 |
191 |
192 | 193 |
194 |

Why custom_ packages?

195 |

The original downloaders don't support new line progress bars. We've modified them to provide better integration with Media Harbor. If they add new line progress bars to their CLI (like yt-dlp), we can use the original versions.

196 |
197 | 198 |
199 |

Having Issues?

200 |

If you're still experiencing problems after manual installation, please create a new issue on our GitHub repository with details about your operating system and the specific error messages you're encountering.

201 |
202 | 203 |
204 | 205 |
206 |
207 |
208 | 209 | 210 | 211 | -------------------------------------------------------------------------------- /src/pages/firststart.js: -------------------------------------------------------------------------------- 1 | const PAGES = ['welcome-page', 'dependencies-page', 'completion-page']; 2 | let currentPageIndex = 0; 3 | 4 | const pageElements = PAGES.map(pageId => document.getElementById(pageId)); 5 | const progressOverlay = document.getElementById('progress-overlay'); 6 | const progressFill = document.querySelector('.progress-fill'); 7 | const progressStatus = document.getElementById('progress-status'); 8 | 9 | document.addEventListener('DOMContentLoaded', async () => { 10 | try { 11 | await window.electronAPI.checkDependencies(); 12 | showPage(currentPageIndex, true); 13 | } catch (error) { 14 | displayError(`Initialization failed: ${error.message}`); 15 | } 16 | }); 17 | 18 | window.electronAPI.handleDependencyStatus((event, status) => { 19 | Object.entries(status).forEach(([dep, isInstalled]) => { 20 | const depElement = document.getElementById(`${dep}-dep`); 21 | if (depElement) { 22 | const statusIcon = depElement.querySelector('.status-icon'); 23 | const installBtn = depElement.querySelector('.install-btn'); 24 | 25 | if (isInstalled) { 26 | statusIcon.textContent = '✅'; 27 | depElement.classList.add('success'); 28 | installBtn.disabled = true; 29 | installBtn.textContent = 'Installed'; 30 | } else { 31 | statusIcon.textContent = '⭕'; 32 | depElement.classList.remove('success'); 33 | installBtn.disabled = false; 34 | installBtn.textContent = 'Install'; 35 | } 36 | } 37 | }); 38 | validateRequiredDependencies(); 39 | }); 40 | 41 | document.querySelectorAll('.install-btn').forEach(button => { 42 | button.addEventListener('click', async ({ target }) => { 43 | const dep = target.dataset.dep; 44 | if (!dep) return; 45 | 46 | showProgress(); 47 | 48 | try { 49 | await window.electronAPI.installDependency(dep); 50 | await window.electronAPI.checkDependencies(); 51 | } catch (error) { 52 | displayError(`Failed to install ${dep}: ${error.message}`); 53 | } finally { 54 | hideProgress(); 55 | } 56 | }); 57 | }); 58 | 59 | document.querySelectorAll('.install-btn').forEach(button => { 60 | button.addEventListener('mousedown', function() { 61 | this.style.transform = 'scale(0.95)'; 62 | }); 63 | 64 | button.addEventListener('mouseup', function() { 65 | this.style.transform = 'scale(1)'; 66 | }); 67 | 68 | button.addEventListener('mouseleave', function() { 69 | this.style.transform = 'scale(1)'; 70 | }); 71 | }); 72 | 73 | const navigateTo = (targetPageIndex) => { 74 | if (targetPageIndex < 0 || targetPageIndex >= PAGES.length || targetPageIndex === currentPageIndex) return; 75 | 76 | const currentPage = pageElements[currentPageIndex]; 77 | const nextPage = pageElements[targetPageIndex]; 78 | 79 | currentPage.classList.remove('active'); 80 | 81 | const onTransitionEnd = () => { 82 | currentPage.style.display = 'none'; 83 | currentPage.removeEventListener('transitionend', onTransitionEnd); 84 | 85 | 86 | nextPage.style.display = 'block'; 87 | 88 | requestAnimationFrame(() => { 89 | nextPage.classList.add('active'); 90 | }); 91 | 92 | currentPageIndex = targetPageIndex; 93 | }; 94 | 95 | currentPage.addEventListener('transitionend', onTransitionEnd); 96 | }; 97 | 98 | const nextPage = () => { 99 | // check if required dependencies are installed 100 | if (currentPageIndex === 1) { 101 | if (!validateRequiredDependencies()) { 102 | displayError("Please install all required dependencies before proceeding."); 103 | return; 104 | } 105 | } 106 | 107 | navigateTo(currentPageIndex + 1); 108 | }; 109 | const prevPage = () => navigateTo(currentPageIndex - 1); 110 | 111 | const showPage = (pageIndex, immediate = false) => { 112 | pageElements.forEach((page, index) => { 113 | if (index === pageIndex) { 114 | page.style.display = 'block'; 115 | if (!immediate) { 116 | requestAnimationFrame(() => { 117 | page.classList.add('active'); 118 | }); 119 | } else { 120 | page.classList.add('active'); 121 | } 122 | } else { 123 | page.style.display = 'none'; 124 | page.classList.remove('active'); 125 | } 126 | }); 127 | }; 128 | 129 | const finishSetup = async () => { 130 | showProgress(); 131 | try { 132 | await window.electronAPI.completeSetup(); 133 | window.electronAPI.restartApp(); 134 | } catch (error) { 135 | displayError(`Failed to complete setup: ${error.message}`); 136 | } finally { 137 | hideProgress(); 138 | } 139 | }; 140 | 141 | const showProgress = () => { 142 | progressOverlay.classList.add('active'); 143 | }; 144 | 145 | const hideProgress = () => { 146 | progressOverlay.classList.remove('active'); 147 | }; 148 | 149 | const updateProgress = (percent, status) => { 150 | progressFill.style.width = `${percent}%`; 151 | progressStatus.textContent = status; 152 | }; 153 | 154 | const displayError = (message) => { 155 | alert(message); 156 | }; 157 | 158 | window.electronAPI.onProgress((event, rawData) => { 159 | console.log('Raw progress data received:', rawData); 160 | 161 | try { 162 | // Parse the JSON string 163 | const data = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; 164 | console.log('Parsed progress data:', data); 165 | 166 | if (data && typeof data === 'object') { 167 | const { percent, status } = data; 168 | updateProgress( 169 | percent || 0, 170 | status || 'Installing...' 171 | ); 172 | } else { 173 | console.error('Invalid progress data format:', data); 174 | } 175 | } catch (error) { 176 | console.error('Error processing progress data:', error); 177 | console.error('Received data:', rawData); 178 | } 179 | }); 180 | 181 | window.electronAPI.onError((event, message) => { 182 | displayError(message); 183 | }); 184 | 185 | function openManualInstallModal() { 186 | const modal = document.getElementById('manual-install-modal'); 187 | modal.style.display = 'block'; 188 | document.body.style.overflow = 'hidden'; 189 | } 190 | 191 | function closeManualInstallModal() { 192 | const modal = document.getElementById('manual-install-modal'); 193 | modal.style.display = 'none'; 194 | document.body.style.overflow = 'auto'; 195 | } 196 | 197 | function validateRequiredDependencies() { 198 | const requiredDeps = ['python', 'git', 'ffmpeg', 'ytdlp']; 199 | let allInstalled = true; 200 | 201 | requiredDeps.forEach(dep => { 202 | const depElement = document.getElementById(`${dep}-dep`); 203 | if (depElement && !depElement.classList.contains('success')) { 204 | allInstalled = false; 205 | } 206 | }); 207 | 208 | const nextButton = document.querySelector('#dependencies-page .next-btn'); 209 | if (nextButton) { 210 | nextButton.disabled = !allInstalled; 211 | 212 | if (!allInstalled) { 213 | nextButton.title = "Please install all required dependencies first"; 214 | } else { 215 | nextButton.title = ""; 216 | } 217 | } 218 | 219 | return allInstalled; 220 | } 221 | 222 | document.addEventListener('DOMContentLoaded', function() { 223 | const warningDiv = document.querySelector('.warning-div'); 224 | if (!warningDiv) return; 225 | const manualInstallLink = warningDiv.querySelector('a'); 226 | const manualInstallBtn = document.createElement('button'); 227 | manualInstallBtn.classList.add('manual-install-btn'); 228 | manualInstallBtn.textContent = 'Manual Installation Guide'; 229 | manualInstallBtn.addEventListener('click', openManualInstallModal); 230 | warningDiv.innerHTML = ''; 231 | const warningText = document.createElement('h3'); 232 | warningText.classList.add('warning'); 233 | warningText.textContent = 'Having trouble with automatic installation? Use our detailed manual guide:'; 234 | warningDiv.appendChild(warningText); 235 | warningDiv.appendChild(manualInstallBtn); 236 | 237 | const modal = document.getElementById('manual-install-modal'); 238 | if (!modal) return; 239 | 240 | const closeBtn = modal.querySelector('.close-modal'); 241 | const closeModalBtn = modal.querySelector('.close-modal-btn'); 242 | closeBtn.addEventListener('click', closeManualInstallModal); 243 | closeModalBtn.addEventListener('click', closeManualInstallModal); 244 | 245 | window.addEventListener('click', function(event) { 246 | if (event.target === modal) { 247 | closeManualInstallModal(); 248 | } 249 | }); 250 | 251 | validateRequiredDependencies(); 252 | }); 253 | -------------------------------------------------------------------------------- /src/pages/help.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Help Tab 7 | 8 | 9 |
10 |

Help Center

11 |
12 |

YT-DLP FAQ

13 |
14 |

Which sites are supported?

15 |

Find supported sites in the generic video downloading tab's supported sites section.

16 |
17 |
18 |
19 |

Downloads FAQ

20 |
21 |

Deleted files aren’t removed from my PC.

22 |

This is expected behavior for now. We’ll fix this in a future update.

23 |

Can't open Downloads page.

24 |

This is normal, because your OS not allows MediaHarbor to create that file. To fix: Open settings and change download database location (don't forget to add ".db" to file name), and restart app.

25 |
26 |
27 |
28 |

Generic FAQ

29 |
30 |

Unable to Download: Invalid Credentials

31 |

This app requires you to sign in with your own account to download from platforms other than YouTube.

32 | 33 |

Signing in to Apple Music and Spotify:

34 |

Install the cookies.txt extension in your browser. Then, navigate to the Apple Music or Spotify page and download the cookies.

35 |

Next, add the cookies to the configurations in the settings.

36 | 37 |

Signing in to Deezer:

38 |

First, open the Deezer player in your browser. Then, access the browser's cookies, locate the arl cookie, copy its value, and paste it into the settings.

39 | 40 |

Signing in to Qobuz:

41 |

You can sign in with your email and password, or with your user ID and token in the settings.

42 |

Downloads get stuck, what to do?

43 |

Make sure to diagnose the issue. Currently, you can start the app from the terminal to debug and create a new issue with log.

44 |

How to report an issue/feature request?

45 |

Visit our main project page and create a new issue: GitHub Issues.

46 |

Which batch downloads are supported?

47 |

Streamrip (Qobuz, Tidal, Deezer), GamDL, Zotify.

48 |

yt-dlp batch download will be added in the next updates.

49 |
50 |
51 |
52 |

53 | 54 | Streamrip FAQ

55 |
56 |

Streamrip downloads (Qobuz, Deezer, Tidal) get stuck, what to do?

57 |

Try running $custom_rip config reset. If unresolved, open DevTools (press alt) and create a new issue with your logs

58 |
59 |
60 |
61 |

Search FAQ

62 |
63 |

Why can't I use play button for albums, artists etc.?

64 |

We will implement a pop-up window to handle watch / listen specific contents in them on the next version.

65 |

Note: Currently play and download buttons are experimental.

66 |
67 |
68 |
69 |

How to Sponsor this Project?

70 |
71 |

Support us via the sponsor button on our project page: GitHub Sponsor.

72 |

All donations are appreciated!

73 |
74 |
75 |
76 |

Contribution

77 |
78 |

Interested in using a CLI on MediaHarbor?

79 |

Create a new issue on the project with NEW_CLI template

80 |
81 |
82 |
83 | 84 | 85 | -------------------------------------------------------------------------------- /src/pages/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | MediaHarbor Desktop 11 | 12 | 13 |
14 |
15 | 18 |

MediaHarbor

19 |
20 |
21 | Music 22 | Video 23 | Downloads 24 | Search 25 |
26 |
27 | Settings 28 | Help 29 |
30 |
31 |
32 |
33 |
34 | Thumbnail 35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 | 45 | 48 | 51 |
52 |
53 | 0:00 54 |
55 |
56 |
57 | 0:00 58 |
59 |
60 | 61 |
62 |
63 | 66 | 67 |
68 | 71 |
72 |
73 | 74 | 75 | -------------------------------------------------------------------------------- /src/pages/music.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Music Tab 7 | 8 | 9 |
10 |
11 | 12 | 13 | 22 | 23 | 32 | 33 | 34 |
35 | 36 |
37 |

YouTube

38 | 39 | 42 |
43 | 44 |
45 | Best 46 | Meh 47 | Worst 48 |
49 |
50 | 51 |
52 | 53 |
54 |

Qobuz

55 | 56 |
57 | 58 |
59 | 24 bit, ≤ 192 kHz 60 | 24 bit, ≤ 96 kHz 61 | 16 bit, 44.1 kHz (CD) 62 | 320 kbps MP3 63 |
64 |
65 | 66 | 67 |
68 | 69 |
70 |

Tidal

71 | 72 |
73 | 74 |
75 | 24 bit, ≤ 96 kHz (MQA) 76 | 16 bit, 44.1 kHz (CD) 77 | 320 kbps AAC 78 | 128 kbps AAC 79 |
80 |
81 | 82 | 83 |
84 | 85 |
86 |

Deezer

87 | 88 |
89 | 90 |
91 | 16 bit, 44.1 kHz (CD) 92 | 320 kbps MP3 93 | 128 kbps MP3 94 |
95 |
96 | 97 | 98 |
99 | 100 |
101 |

Spotify

102 | 103 |
104 | 105 |
106 | Best 107 | Very High 108 | High 109 | Normal 110 |
111 |
112 | 113 | 114 |
115 | 116 |
117 |

Apple Music

118 | 119 |
120 | 121 |
122 | 256 Kbps 123 | 64 kbps 124 |
125 |
126 | 136 | 137 | 138 |
139 | 140 |
141 |

Streamrip

142 | 143 | 144 |
145 | 146 |
147 |
148 |
149 | 150 | -------------------------------------------------------------------------------- /src/pages/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MediaHarbor/mediaharbor/4e33b2c599c10af3f2e4718ab3a16d3e74530259/src/pages/placeholder.png -------------------------------------------------------------------------------- /src/pages/search.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Search 6 | 7 | 8 |
9 |
10 | 11 | 12 | 13 | 22 | 23 | 32 | 33 |
34 |
35 |

YouTube

36 | 37 |
38 | 39 |
40 | Videos 41 | Playlists 42 | Channels 43 |
44 |
45 | 46 |
47 |
48 |

YouTube Music

49 | 50 |
51 | 52 |
53 | Tracks 54 | Albums 55 | Playlists 56 | Artists 57 | Podcasts 58 | Episodes 59 |
60 |
61 | 62 |
63 |
64 |

Spotify

65 | 66 |
67 | 68 |
69 | Tracks 70 | Albums 71 | Playlists 72 | Podcasts 73 | Episodes 74 | Artists 75 |
76 |
77 | 78 |
79 |
80 |

Deezer

81 | 82 |
83 | 84 |
85 | Tracks 86 | Albums 87 | Playlists 88 | Artists 89 | 92 |
93 |
94 | 95 |
96 |
97 |

Qobuz

98 | 99 |
100 | 101 |
102 | Tracks 103 | Albums 104 | Playlists 105 | Artists 106 |
107 |
108 | 109 |
110 |
111 |

Tidal

112 | 113 |
114 | 115 |
116 | Tracks 117 | Albums 118 | Videos 119 | Artists 120 |
121 |
122 | 123 |
124 |
125 |

Apple Music

126 | 127 |
128 | 129 |
130 | Tracks 131 | Albums 132 | Artists 133 |
134 |
135 | 136 |
137 | 138 |
139 |
140 | × 141 |
142 | Album Thumbnail 143 |
144 |

Album Name

145 |

Album Description

146 |
147 |
148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 |
#Track TitleDurationDownloadPlayCopy
163 |
164 |
165 | 166 |
167 |
168 |
169 | 170 | -------------------------------------------------------------------------------- /src/pages/video.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Video Tab 7 | 8 | 9 |
10 |
11 | 12 | 13 |
14 | 15 |
16 |

YouTube

17 | 18 | 22 |
23 | 24 |
25 | Best Quality 26 | 4K60 27 | 4K 28 | 2K60 29 | 2K 30 | 1080p60 31 | 1080p 32 | 720p60 33 | 720p 34 | 480p 35 | 360p 36 | 240p 37 | 144p 38 |
39 |
40 | 41 |
42 | 43 |
44 |

Generic

45 | 46 |
47 | 48 |
49 | Best Quality 50 | 1080p 51 | 960p 52 | 720p 53 | 540p 54 | 480p 55 | 360p 56 | 240p 57 | 144p 58 |
59 |
60 |
61 | 62 |
63 | 64 |
65 | 66 |
67 |
68 |
69 | 70 |
71 | 72 |
73 |
74 | 75 | -------------------------------------------------------------------------------- /src/preload.js: -------------------------------------------------------------------------------- 1 | const { contextBridge, ipcRenderer } = require('electron'); 2 | 3 | // Combine all electronAPI methods into a single object 4 | contextBridge.exposeInMainWorld('electronAPI', { 5 | copyText: (text) => ipcRenderer.invoke('copy-handler', text), 6 | // Download-related methods 7 | getAlbumDetails: (platform, albumId) => ipcRenderer.invoke('get-album-details', platform, albumId), 8 | getPlaylistDetails: (platform, playlistId) => ipcRenderer.invoke('get-playlist-details', platform, playlistId), 9 | getDownloads: () => ipcRenderer.invoke('load-downloads'), 10 | 11 | // Existing channel handlers 12 | send: (channel, data) => { 13 | const validChannels = [ 14 | 'start-yt-music-download', 15 | 'start-yt-video-download', 16 | 'start-generic-video-download', 17 | 'minimize-window', 18 | 'maximize-window', 19 | 'close-window', 20 | 'start-streamrip', 21 | 'start-download', 22 | 'start-qobuz-download', 23 | 'start-deezer-download', 24 | 'start-tidal-download', 25 | 'save-settings', 26 | 'load-settings', 27 | 'get-default-settings', 28 | 'download-complete', 29 | 'download-error', 30 | 'start-qobuz-batch-download', 31 | 'start-tidal-batch-download', 32 | 'start-deezer-batch-download', 33 | 'start-apple-download', 34 | 'start-spotify-download', 35 | 'clear-database', 36 | 'start-apple-batch-download', 37 | 'start-spotify-batch-download', 38 | 'install-services', 39 | 'spawn-tidal-config', 40 | 'updateDep', 41 | ]; 42 | if (validChannels.includes(channel)) { 43 | ipcRenderer.send(channel, data); 44 | } 45 | }, 46 | 47 | receive: (channel, func) => { 48 | ipcRenderer.on(channel, (event, ...args) => func(...args)); 49 | }, 50 | showFirstStart: (callback) => ipcRenderer.on('show-first-start', callback), 51 | completeFirstRun: () => ipcRenderer.send('first-run-complete'), 52 | deleteDownload: (id) => ipcRenderer.invoke('deleteDownload', id), 53 | showItemInFolder: (location) => ipcRenderer.invoke('showItemInFolder', location), 54 | clearDownloadsDatabase: () => ipcRenderer.invoke('clearDownloadsDatabase'), 55 | fileLocation: () => ipcRenderer.invoke('dialog:saveFile'), 56 | folderLocation: () => ipcRenderer.invoke('dialog:openFolder'), 57 | fileSelectLocation: () => ipcRenderer.invoke('dialog:openFile'), 58 | openWvdLocation: () => ipcRenderer.invoke('dialog:openwvdFile'), 59 | 60 | checkDependencies: () => ipcRenderer.invoke('check-dependencies'), 61 | handleDependencyStatus: (callback) => { 62 | ipcRenderer.on('dependency-status', callback); 63 | }, 64 | 65 | // Installation 66 | installDependency: (dep) => ipcRenderer.invoke('install-dependency', dep), 67 | 68 | // Setup completion 69 | completeSetup: () => ipcRenderer.invoke('complete-setup'), 70 | restartApp: () => ipcRenderer.send('restart-app'), 71 | 72 | // Progress updates 73 | onProgress: (callback) => { 74 | ipcRenderer.on('installation-progress', (_event, data) => { 75 | callback(null, data); 76 | }); 77 | }, 78 | 79 | // Error handling 80 | onError: (callback) => { 81 | ipcRenderer.on('installation-message', (_event, data) => { 82 | callback(null, typeof data === 'string' ? data : JSON.stringify(data)); 83 | }); 84 | }, 85 | 86 | // Settings 87 | saveSettings: (settings) => ipcRenderer.invoke('save-settings', settings), 88 | getSettings: () => ipcRenderer.invoke('get-settings'), 89 | }); 90 | contextBridge.exposeInMainWorld( 91 | 'api', { 92 | getQobuzTrackList: (data) => ipcRenderer.invoke('get-qobuz-track-list', data), 93 | // First launch 94 | refreshApp: () => {return ipcRenderer.send('refresh-app')}, 95 | getDefaultSettings: () => ipcRenderer.invoke('get-default-settings'), 96 | 97 | // Search methods 98 | performSearch: (searchData) => { 99 | return ipcRenderer.invoke('perform-search', searchData); 100 | }, 101 | // Listen to events 102 | onSearchResults: (callback) => { 103 | ipcRenderer.on('search-results', (event, ...args) => callback(...args)); 104 | }, 105 | 106 | playMedia: (args) => { 107 | // Return the promise directly 108 | return ipcRenderer.invoke('play-media', args); 109 | }, 110 | 111 | onStreamReady: (callback) => { 112 | ipcRenderer.removeAllListeners('stream-ready'); 113 | ipcRenderer.on('stream-ready', (event, data) => callback(data)); 114 | }, 115 | 116 | onError: (callback) => { 117 | ipcRenderer.on('error', (event, ...args) => callback(...args)); 118 | } 119 | } 120 | ); 121 | contextBridge.exposeInMainWorld("electron", { 122 | ipcRenderer: { 123 | send: (channel, data) => ipcRenderer.send(channel, data), 124 | on: (channel, func) => ipcRenderer.on(channel, (event, ...args) => func(...args)), 125 | }, 126 | invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), 127 | }); 128 | contextBridge.exposeInMainWorld('errorNotifier', { 129 | onError: (callback) => { 130 | ipcRenderer.on('out-error', (event, message) => { 131 | callback(message); 132 | }); 133 | }, 134 | }); 135 | 136 | contextBridge.exposeInMainWorld('protocolHandler', { 137 | onProtocolAction: (callback) => { 138 | ipcRenderer.on('protocol-action', (event, data) => callback(data)); 139 | } 140 | }); --------------------------------------------------------------------------------