Compare commits

..

No commits in common. "main" and "1.4.2" have entirely different histories.
main ... 1.4.2

20 changed files with 695 additions and 9785 deletions

View file

@ -1,10 +1,10 @@
name: Build LoaderSpot
name: Build Windows LoaderSpot UI
on:
workflow_dispatch:
jobs:
build_windows:
build:
runs-on: windows-latest
strategy:
matrix:
@ -17,179 +17,79 @@ jobs:
rust_target: aarch64-pc-windows-msvc
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v5
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Install Rust toolchain and target
uses: actions-rs/toolchain@v1
with:
toolchain: stable
profile: minimal
override: true
targets: ${{ matrix.rust_target }}
- name: Cache cargo dependencies
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
LoaderSpot_UI/target
LoaderSpot_CLI/target
key: ${{ runner.os }}-cargo-${{ matrix.rust_target }}-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-${{ matrix.rust_target }}-
- name: Build release binaries
- name: Build release binary
env:
RUST_TARGET: ${{ matrix.rust_target }}
ARCH: ${{ matrix.arch }}
shell: pwsh
run: |
$ErrorActionPreference = "Stop"
# fail on first error
Set-PSDebug -Strict
# build release for target
Write-Host "Building target: $env:RUST_TARGET"
cargo build --manifest-path LoaderSpot_UI/Cargo.toml --release --target $env:RUST_TARGET
# prepare artifacts folder
$out = "artifacts"
if (Test-Path $out) { Remove-Item $out -Recurse -Force }
New-Item -ItemType Directory -Path $out | Out-Null
Write-Host "Building UI for $env:RUST_TARGET"
cargo build --manifest-path LoaderSpot_UI/Cargo.toml --release --target $env:RUST_TARGET
$exeUI = Get-ChildItem "LoaderSpot_UI/target/$env:RUST_TARGET/release/*.exe" | Select-Object -First 1
Copy-Item $exeUI.FullName "$out\loaderspot-ui-win-$env:ARCH.exe"
Write-Host "UI executable ready: loaderspot-ui-win-$env:ARCH.exe"
Write-Host "Building CLI for $env:RUST_TARGET"
cargo build --manifest-path LoaderSpot_CLI/Cargo.toml --release --target $env:RUST_TARGET
$exeCLI = Get-ChildItem "LoaderSpot_CLI/target/$env:RUST_TARGET/release/*.exe" | Select-Object -First 1
Copy-Item $exeCLI.FullName "$out\loaderspot-cli-win-$env:ARCH.exe"
Write-Host "CLI executable ready: loaderspot-cli-win-$env:ARCH.exe"
- name: Upload Windows artifacts
uses: actions/upload-artifact@v7
# locate produced executable in target/<target>/release
$exePath = Join-Path -Path "LoaderSpot_UI" -ChildPath (Join-Path -Path "target" -ChildPath (Join-Path -Path $env:RUST_TARGET -ChildPath "release"))
Write-Host "Searching for exe in: $exePath"
$exe = Get-ChildItem -Path $exePath -Filter "*.exe" -File -ErrorAction SilentlyContinue | Select-Object -First 1
if (-not $exe) {
Write-Error "Executable not found in $exePath"
exit 1
}
# copy and archive artifact
Copy-Item $exe.FullName -Destination "$out\loaderspot_ui_${env:ARCH}.exe" -Force
Compress-Archive -Path "$out\loaderspot_ui_${env:ARCH}.exe" -DestinationPath "$out\loaderspot_ui_${env:ARCH}.zip" -Force
- name: Upload artifact
uses: actions/upload-artifact@v5
with:
name: windows-artifacts-${{ matrix.arch }}
path: artifacts/*
name: loaderspot_ui_${{ matrix.arch }}
path: artifacts/loaderspot_ui_${{ matrix.arch }}.zip
build_linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Get latest release
id: latest_release
uses: actions/github-script@v8
with:
script: |
const releases = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo
});
if (!releases.data || releases.data.length === 0) {
core.setFailed('No releases found for repository');
}
return releases.data[0].id;
result-encoding: string
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-unknown-linux-gnu
- name: Cache cargo dependencies
uses: actions/cache@v5
with:
path: |
~/.cargo/registry
~/.cargo/git
LoaderSpot_UI/target
LoaderSpot_CLI/target
key: ${{ runner.os }}-cargo-x86_64-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-x86_64-
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y pkg-config libssl-dev
- name: Build release binaries
run: |
mkdir -p artifacts
# Build UI for Linux
echo "Building UI for x86_64-unknown-linux-gnu"
cargo build --manifest-path LoaderSpot_UI/Cargo.toml --release --target x86_64-unknown-linux-gnu
cp "LoaderSpot_UI/target/x86_64-unknown-linux-gnu/release/loaderspot_ui" "artifacts/loaderspot-ui-linux-x64"
echo "UI executable ready: loaderspot-ui-linux-x64"
# Build CLI for Linux
echo "Building CLI for x86_64-unknown-linux-gnu"
cargo build --manifest-path LoaderSpot_CLI/Cargo.toml --release --target x86_64-unknown-linux-gnu
cp "LoaderSpot_CLI/target/x86_64-unknown-linux-gnu/release/loaderspot_cli" "artifacts/loaderspot-cli-linux-x64"
echo "CLI executable ready: loaderspot-cli-linux-x64"
- name: Upload Linux artifacts
uses: actions/upload-artifact@v7
with:
name: linux-artifacts-x64
path: artifacts/*
release:
needs: [build_windows, build_linux]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
- name: Get latest release
id: latest_release
uses: actions/github-script@v8
with:
script: |
try {
const release = await github.rest.repos.getLatestRelease({
owner: context.repo.owner,
repo: context.repo.repo
});
return release.data.id;
} catch (error) {
core.setFailed(`No releases found: ${error.message}`);
}
- name: Upload assets to Release
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const path = require('path');
const releaseId = parseInt('${{ steps.latest_release.outputs.result }}', 10);
async function uploadAsset(filePath) {
const fileName = path.basename(filePath);
console.log(`Processing ${fileName}`);
const assets = await github.rest.repos.listReleaseAssets({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId
});
const existingAsset = assets.data.find(a => a.name === fileName);
if (existingAsset) {
console.log(`Deleting existing asset: ${fileName}`);
await github.rest.repos.deleteReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
asset_id: existingAsset.id
});
}
console.log(`Uploading: ${fileName}`);
await github.rest.repos.uploadReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
name: fileName,
data: fs.readFileSync(filePath)
});
}
const files = [];
const artifactDirs = fs.readdirSync('artifacts');
for (const dir of artifactDirs) {
const dirPath = path.join('artifacts', dir);
const dirFiles = fs.readdirSync(dirPath);
for (const file of dirFiles) {
files.push(path.join(dirPath, file));
}
}
for (let i = 0; i < files.length; i += 3) {
const batch = files.slice(i, i + 3);
await Promise.all(batch.map(uploadAsset));
}
- name: Upload to Release
uses: actions/github-script@v8
with:
script: |
const fs = require('fs');
const releaseId = parseInt('${{ steps.latest_release.outputs.result }}', 10);
const path = `artifacts/loaderspot_ui_${{ matrix.arch }}.zip`;
const content = fs.readFileSync(path);
await github.rest.repos.uploadReleaseAsset({
owner: context.repo.owner,
repo: context.repo.repo,
release_id: releaseId,
name: `loaderspot_ui_${{ matrix.arch }}.zip`,
data: content
});

View file

@ -13,7 +13,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
token: ${{ secrets.PERSONAL_TOKEN }}

View file

@ -1,6 +1,8 @@
name: check Spotify MS
on:
schedule:
- cron: '25 * * * *'
workflow_dispatch:
jobs:
@ -9,7 +11,7 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: run ms-spotify-check.ps1
shell: pwsh

View file

@ -1,15 +1,13 @@
name: Sync files to table
on:
# Temporarily disabled automatic sync trigger.
# Uncomment this block to restore automatic runs on push to main.
# push:
# branches:
# - main
# paths:
# - 'versions.json'
# - 'versions_deb.json'
workflow_dispatch:
push:
branches:
- main
paths:
- 'versions.json'
- 'versions_deb.json'
workflow_dispatch:
jobs:
sync-files:
@ -19,10 +17,10 @@ jobs:
steps:
- name: Checkout source repo
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Checkout target repo
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
repository: LoaderSpot/table
path: table

View file

@ -8,11 +8,7 @@ on:
required: true
version:
description: 'Another version parameter'
required: true
buildType:
description: 'Build type (e.g., Master, Release)'
required: false
default: 'false'
required: true
jobs:
update-version:
runs-on: ubuntu-latest
@ -21,7 +17,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
with:
token: ${{ secrets.PERSONAL_TOKEN }}
@ -33,8 +29,7 @@ jobs:
- name: Update version in versions.json
run: |
data="${{ github.event.inputs.datajson }}"
build_type="${{ github.event.inputs.buildType }}"
python3 update_version.py "$data" "$build_type"
python3 -c "import json, re; data=json.loads('$data'); file_data=json.load(open('versions.json')); file_data[list(data.keys())[0]] = data[list(data.keys())[0]]; file_data = dict(sorted(file_data.items(), key=lambda x: [int(part) if part.isdigit() else part for part in re.split(r'(\d+)', x[0])], reverse=True)); json.dump(file_data, open('versions.json', 'w'), indent=2)"
- name: Commit changes
run: |

View file

@ -8,47 +8,21 @@ concurrency:
cancel-in-progress: false
jobs:
version-search:
build:
runs-on: ubuntu-latest
outputs:
versions: ${{ steps.search.outputs.versions }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v5
- name: Download latest loaderspot_cli_linux_x64
env:
GH_TOKEN: ${{ github.token }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: 3.12.2
- name: Install dependencies
run: |
gh release download --repo ${{ github.repository }} --pattern "loaderspot-cli-linux-x64" --output loaderspot_cli
chmod +x loaderspot_cli
pip install aiohttp beautifulsoup4 argparse
- name: Version search
id: search
if: github.event.client_payload.v && github.event.client_payload.s
env:
v: ${{ github.event.client_payload.v }}
s: ${{ github.event.client_payload.s }}
run: |
{
echo 'versions<<EOF'
./loaderspot_cli --version "$v" --connections 300 --ladder-search
echo 'EOF'
} >> "$GITHUB_OUTPUT"
get-build-and-send:
runs-on: windows-latest
needs: version-search
if: success() && needs.version-search.outputs.versions
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Get build info and send to GAS
shell: pwsh
env:
GOOGLE_APPS_URL: ${{ secrets.GOOGLE_APPS_URL }}
VERSIONS: ${{ needs.version-search.outputs.versions }}
SOURCE: ${{ github.event.client_payload.s }}
run: |
./check-build-version.ps1 -versions "$env:VERSIONS" -source "$env:SOURCE" -googleAppsUrl "$env:GOOGLE_APPS_URL"
run: python ${{ github.workspace }}/upd.py -v ${{ github.event.client_payload.v }} -s "${{ github.event.client_payload.s }}" -u "${{ secrets.GOOGLE_APPS_URL }}"

2
.gitignore vendored
View file

@ -1,2 +0,0 @@
/LoaderSpot_CLI/target
/LoaderSpot_UI/target

309
LoaderSpot.py Normal file
View file

@ -0,0 +1,309 @@
import re
import json
import aiohttp
import asyncio
from tqdm import tqdm
from typing import Optional, Tuple, List, Dict
from dataclasses import dataclass
from enum import Enum
class Platform(Enum):
WIN_X86 = "Win-x86"
WIN_X64 = "Win-x64"
WIN_ARM64 = "Win-arm64"
MACOS_INTEL = "macOS-intel"
MACOS_ARM64 = "macOS-arm64"
@dataclass
class SpotifyVersion:
version: str
start_number: int
end_number: int
def extract_base_version(version: str) -> str:
parts = version.split('.')
if len(parts) >= 3:
return f"{parts[0]}.{parts[1]}.{parts[2]}"
return version
def should_use_win_x86(version: str) -> bool:
base_version = extract_base_version(version)
try:
# Split by dot and compare parts
parts = base_version.split('.')
version_tuple = (int(parts[0]), int(parts[1]), int(parts[2]))
# Compare with version 1.2.53 using tuple comparison
return version_tuple <= (1, 2, 53)
except (ValueError, IndexError):
# If there's any error in parsing, default to include
return True
class UrlGenerator:
BASE_URL = "https://upgrade.scdn.co/upgrade/client/"
PLATFORM_PATHS = {
Platform.WIN_X86: "win32-x86/spotify_installer-{version}-{number}.exe",
Platform.WIN_X64: "win32-x86_64/spotify_installer-{version}-{number}.exe",
Platform.WIN_ARM64: "win32-arm64/spotify_installer-{version}-{number}.exe",
Platform.MACOS_INTEL: "osx-x86_64/spotify-autoupdate-{version}-{number}.tbz",
Platform.MACOS_ARM64: "osx-arm64/spotify-autoupdate-{version}-{number}.tbz",
}
@staticmethod
def generate_url(platform: Platform, version: str, number: int) -> str:
return f"{UrlGenerator.BASE_URL}{UrlGenerator.PLATFORM_PATHS[platform].format(version=version, number=number)}"
async def check_url(
session: aiohttp.ClientSession, url: str, platform: Platform
) -> Optional[Tuple[str, Platform]]:
try:
async with session.head(url) as response:
if response.status == 200:
return url, platform
except aiohttp.ClientError:
pass
return None
async def fetch_versions_json(session: aiohttp.ClientSession) -> dict:
async with session.get(
"https://raw.githubusercontent.com/amd64fox/LoaderSpot/refs/heads/main/versions.json"
) as response:
if response.status == 200:
try:
return json.loads(await response.text())
except json.JSONDecodeError:
return {}
return {}
async def submit_to_google_form(session: aiohttp.ClientSession, version: str, comment: str = "from LoaderSpot") -> None:
form_url = "https://docs.google.com/forms/u/0/d/e/1FAIpQLSdqIxSjqt2PcjBlQzhvwqc4QckfWuq5qqWsrdpoTidQHsPGpw/formResponse"
data = {"entry.1104502920": version, "entry.1319854718": comment}
try:
await session.post(form_url, data=data)
except Exception:
pass
async def check_version_and_submit(session: aiohttp.ClientSession, version: str) -> None:
try:
versions_json = await fetch_versions_json(session)
version_exists = any(
ver_data.get("fullversion") == version
for ver_data in versions_json.values()
)
if not version_exists:
await submit_to_google_form(session, version)
except Exception:
pass
def validate_version(version: str) -> bool:
return bool(re.match(r"^\d+\.\d+\.\d+\.\d+\.g[0-9a-f]{8}$", version))
def validate_number(number: str) -> bool:
return number.isdigit()
def validate_range(start: int, end: int) -> bool:
return end >= start and (end - start) <= 20000
def get_valid_input(prompt: str, validator: callable, error_message: str) -> str:
while True:
value = input(prompt)
if validator(value):
return value
print(error_message)
def get_version_input() -> str:
while True:
version = input("Spotify version, for example 1.1.68.632.g2b11de83: ")
if validate_version(version):
return version
print("Invalid version format")
def calculate_total_urls(
start_number: int, end_number: int, selected_platforms: List[Platform]
) -> int:
return (end_number - start_number + 1) * len(selected_platforms)
async def search_installers(
session: aiohttp.ClientSession, version_info: SpotifyVersion, selected_platforms: List[Platform]
) -> Dict[Platform, List[str]]:
tasks = []
total_urls = calculate_total_urls(
version_info.start_number, version_info.end_number, selected_platforms
)
for platform in selected_platforms:
for number in range(version_info.start_number, version_info.end_number + 1):
url = UrlGenerator.generate_url(platform, version_info.version, number)
tasks.append(check_url(session, url, platform))
results: Dict[Platform, List[str]] = {platform: [] for platform in Platform}
avg_speed = [0, 0]
custom_bar_format = "{desc}: {percentage:3.0f}%|{bar}| {n_fmt}/{total_fmt} [{elapsed}<{remaining}"
with tqdm(total=total_urls, desc="Checking URLs",
bar_format=custom_bar_format + ", ? urls/sec]") as pbar:
def update_speed(rate):
nonlocal custom_bar_format
speed_str = f", {int(rate)} urls/sec]"
pbar.bar_format = custom_bar_format + speed_str
for task in asyncio.as_completed(tasks):
if result := await task:
url, platform = result
results[platform].append(url)
pbar.update(1)
rate = pbar.format_dict.get('rate', 0)
if rate > 0:
avg_speed[0] += rate
avg_speed[1] += 1
update_speed(rate)
if avg_speed[1] > 0:
avg_rate = avg_speed[0] / avg_speed[1]
final_speed_str = f", {int(avg_rate)} urls/sec (avg)]"
pbar.bar_format = custom_bar_format + final_speed_str
pbar.refresh()
return results
def display_results(results: Dict[Platform, List[str]]) -> None:
found_any = False
for platform in Platform:
if urls := results[platform]:
found_any = True
print(f"\n{platform.value}:")
for url in urls:
print(url)
if not found_any:
print("\nNothing found, consider increasing the search range")
def get_platform_choices(version_spoti: str = "") -> List[Platform]:
print("\nSelect the link type for the search:")
# Filter out WIN_X86 platform for versions > 1.2.53
available_platforms = list(Platform)
if version_spoti and not should_use_win_x86(version_spoti):
available_platforms = [p for p in Platform if p != Platform.WIN_X86]
for i, platform in enumerate(available_platforms, 1):
print(f"[{i}] {platform.value}")
print(f"[{len(available_platforms) + 1}] All platforms")
while True:
choices = input("Enter the number(s): ").strip().split(",")
if not all(choice.strip().isdigit() and 1 <= int(choice.strip()) <= len(available_platforms) + 1 for choice in choices):
print(f"Invalid input. Please enter numbers between 1 and {len(available_platforms) + 1}")
continue
if str(len(available_platforms) + 1) in choices:
return available_platforms
selected = []
for choice in choices:
platform_index = int(choice.strip()) - 1
if 0 <= platform_index < len(available_platforms):
selected.append(available_platforms[platform_index])
if selected:
return selected
print("Please select at least one valid platform")
def get_max_connections() -> int:
while True:
max_connections = input("Maximum number of concurrent connections (default 100): ").strip()
if not max_connections:
return 100
if max_connections.isdigit() and int(max_connections) > 0:
return int(max_connections)
print("Please enter a valid positive number")
async def main(version_spoti: str = "") -> None:
# Increase connection limit
max_connections = get_max_connections()
connector = aiohttp.TCPConnector(limit=max_connections)
async with aiohttp.ClientSession(connector=connector) as session:
if not version_spoti:
version_spoti = get_version_input()
version_check_task = asyncio.create_task(check_version_and_submit(session, version_spoti))
start_number = int(
get_valid_input(
"Start search from: ",
lambda x: validate_number(x),
"Please enter a valid number",
)
)
end_number = int(
get_valid_input(
"End search at: ",
lambda x: validate_number(x) and validate_range(start_number, int(x)),
f"Please enter a valid number that is at least {start_number} and no more than {start_number + 20000}",
)
)
version_info = SpotifyVersion(version_spoti, start_number, end_number)
selected_platforms = get_platform_choices(version_spoti)
print("\nSearching...\n")
results = await search_installers(session, version_info, selected_platforms)
display_results(results)
try:
await asyncio.wait_for(version_check_task, timeout=1.0)
except asyncio.TimeoutError:
pass
print("\nChoose an option:")
print("[1] Perform the search with a new version")
print("[2] Perform the search again with the same version")
print("[3] Exit")
choice = input("Enter the number: ")
if choice == "1":
print("\n")
await main()
elif choice == "2":
print("\n")
print(f"Search version: {version_spoti}")
await main(version_spoti)
elif choice == "3":
return
else:
print("Invalid choice. Exiting the program.")
if __name__ == "__main__":
asyncio.run(main())

2118
LoaderSpot_CLI/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,14 +0,0 @@
[package]
name = "loaderspot_cli"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.0", features = ["derive"] }
indicatif = "0.17.7"
regex = "1"
scraper = "0.19.0"

View file

@ -1,341 +0,0 @@
use clap::Parser;
use indicatif::{ProgressBar, ProgressStyle};
use reqwest::Client;
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tokio::sync::Semaphore;
use regex::Regex;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
enum PlatformArch {
WinX86,
WinX64,
WinArm64,
MacOsIntel,
MacOsArm64,
}
impl PlatformArch {
fn path_template(&self) -> &str {
match self {
PlatformArch::WinX86 => "win32-x86/spotify_installer-{version}-{number}.exe",
PlatformArch::WinX64 => "win32-x86_64/spotify_installer-{version}-{number}.exe",
PlatformArch::WinArm64 => "win32-arm64/spotify_installer-{version}-{number}.exe",
PlatformArch::MacOsIntel => "osx-x86_64/spotify-autoupdate-{version}-{number}.tbz",
PlatformArch::MacOsArm64 => "osx-arm64/spotify-autoupdate-{version}-{number}.tbz",
}
}
fn to_string(&self) -> &str {
match self {
PlatformArch::WinX86 => "WIN32",
PlatformArch::WinX64 => "WIN64",
PlatformArch::WinArm64 => "WIN-ARM64",
PlatformArch::MacOsIntel => "OSX",
PlatformArch::MacOsArm64 => "OSX-ARM64",
}
}
}
struct UrlGenerator;
impl UrlGenerator {
const BASE_URL: &'static str = "https://upgrade.scdn.co/upgrade/client/";
fn generate_url(platform: PlatformArch, version: &str, number: i32) -> String {
let path = platform
.path_template()
.replace("{version}", version)
.replace("{number}", &number.to_string());
format!("{}{}", Self::BASE_URL, path)
}
}
fn extract_base_version(version: &str) -> String {
let parts: Vec<&str> = version.split('.').collect();
if parts.len() >= 3 {
format!("{}.{}.{}", parts[0], parts[1], parts[2])
} else {
version.to_string()
}
}
fn should_use_win_x86(version: &str) -> bool {
let base_version = extract_base_version(version);
let parts: Vec<&str> = base_version.split('.').collect();
if parts.len() >= 3 {
if let (Ok(major), Ok(minor), Ok(patch)) = (
parts[0].parse::<u32>(),
parts[1].parse::<u32>(),
parts[2].parse::<u32>(),
) {
return (major, minor, patch) <= (1, 2, 53);
}
}
true
}
async fn check_url(client: &Client, url: String, platform: PlatformArch) -> Option<(String, PlatformArch)> {
match client.head(&url).send().await {
Ok(response) if response.status().is_success() => Some((url, platform)),
_ => None,
}
}
async fn search_installers(
client: &Client,
version: &str,
start: i32,
end: i32,
platform: PlatformArch,
max_connections: usize,
) -> Vec<(String, PlatformArch)> {
let semaphore = Arc::new(Semaphore::new(max_connections));
let mut tasks = Vec::new();
let found_urls = Arc::new(tokio::sync::Mutex::new(Vec::new()));
for number in start..=end {
let url = UrlGenerator::generate_url(platform, version, number);
let client = client.clone();
let permit = semaphore.clone().acquire_owned().await.unwrap();
let found_urls_clone = found_urls.clone();
let task = tokio::spawn(async move {
let result = check_url(&client, url, platform).await;
drop(permit);
if let Some(found_data) = result {
let mut guard = found_urls_clone.lock().await;
guard.push(found_data);
}
});
tasks.push(task);
}
for task in tasks {
let _ = task.await;
}
let guard = found_urls.lock().await;
guard.clone()
}
#[derive(Parser, Debug)]
#[clap(author, version, about, long_about = None, disable_version_flag = true)]
struct Cli {
/// Spotify version(s) to search for
#[clap(long, required = true, use_value_delimiter = true, value_delimiter = ',')]
version: Vec<String>,
/// Range of build numbers to check (e.g., 0-5000)
#[clap(long, default_value = "0-5000")]
range: String,
/// Architecture(s)
#[clap(long, use_value_delimiter = true, value_delimiter = ',', value_parser = ["x86", "x64", "arm64", "intel", "all"], default_value = "all")]
arch: Vec<String>,
/// Platform(s)
#[clap(long, name = "os", use_value_delimiter = true, value_delimiter = ',', value_parser = ["win", "mac", "all"], default_value = "all")]
platform: Vec<String>,
/// Number of concurrent connections
#[clap(long, default_value_t = 100)]
connections: usize,
/// Use ladder search algorithm
#[clap(long)]
ladder_search: bool,
}
fn parse_range(range_str: &str) -> (i32, i32) {
let parts: Vec<&str> = range_str.split('-').collect();
if parts.len() == 2 {
let start = parts[0].parse().unwrap_or(0);
let end = parts[1].parse().unwrap_or(5000);
(start, end)
} else {
(0, 5000)
}
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let platforms = if cli.platform.contains(&"all".to_string()) {
vec!["win", "mac"]
} else {
cli.platform.iter().map(|s| s.as_str()).collect()
};
let arches = if cli.arch.contains(&"all".to_string()) {
if platforms.contains(&"win") && platforms.contains(&"mac") {
vec!["x86", "x64", "arm64", "intel", "arm64"]
} else if platforms.contains(&"win") {
vec!["x86", "x64", "arm64"]
} else if platforms.contains(&"mac") {
vec!["intel", "arm64"]
} else {
vec![]
}
} else {
cli.arch.iter().map(|s| s.as_str()).collect()
};
let mut platform_arches = Vec::new();
for platform in &platforms {
for arch in &arches {
let platform_arch = match (*platform, *arch) {
("win", "x86") => Some(PlatformArch::WinX86),
("win", "x64") => Some(PlatformArch::WinX64),
("win", "arm64") => Some(PlatformArch::WinArm64),
("mac", "intel") => Some(PlatformArch::MacOsIntel),
("mac", "arm64") => Some(PlatformArch::MacOsArm64),
_ => None,
};
if let Some(pa) = platform_arch {
if !platform_arches.contains(&pa) {
platform_arches.push(pa);
}
}
}
}
if platform_arches.is_empty() {
eprintln!("Error: No valid platform and architecture combinations provided.");
std::process::exit(1);
}
let connections = cli.connections.clamp(50, 300);
let range = cli.range.clone();
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.unwrap();
let spinner_style = ProgressStyle::with_template("{spinner}")
.unwrap()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"); // A classic rotating spinner
let pb = ProgressBar::new_spinner();
pb.set_style(spinner_style);
pb.enable_steady_tick(Duration::from_millis(80));
let versions = cli.version.clone();
let client_clone = client.clone();
let ladder_search = cli.ladder_search;
let versions_clone_for_task = versions.clone();
let search_task = tokio::spawn(async move {
let mut results = Vec::new();
for version in &versions_clone_for_task {
let mut all_found_urls_for_version = Vec::new();
let mut arches_to_search = platform_arches.clone();
if !should_use_win_x86(version) && arches_to_search.contains(&PlatformArch::WinX86) {
if versions_clone_for_task.len() == 1 && arches_to_search.len() == 1 {
eprintln!("Warning: x86 architecture for Windows is no longer supported for versions newer than 1.2.53.");
continue;
}
arches_to_search.retain(|&p| p != PlatformArch::WinX86);
}
if ladder_search {
let mut start_number = 0;
let mut before_enter = 1000;
let additional_searches = 15;
let increment = 1000;
for &platform_arch in &arches_to_search {
let found = search_installers(&client_clone, version, start_number, before_enter, platform_arch, connections).await;
all_found_urls_for_version.extend(found);
}
for _ in 0..additional_searches {
let latest_urls = get_latest_urls(&all_found_urls_for_version);
let target_len = arches_to_search.iter().filter(|&&p| p != PlatformArch::WinX86 || should_use_win_x86(version)).count();
if latest_urls.len() >= target_len {
break;
}
start_number = before_enter + 1;
before_enter += increment;
let mut missing_arches = Vec::new();
for &platform_arch in &arches_to_search {
if !latest_urls.contains_key(platform_arch.to_string()) {
missing_arches.push(platform_arch);
}
}
for &platform_arch in &missing_arches {
let found = search_installers(&client_clone, version, start_number, before_enter, platform_arch, connections).await;
all_found_urls_for_version.extend(found);
}
}
} else {
let (start, end) = parse_range(&range);
for &platform_arch in &arches_to_search {
let found = search_installers(&client_clone, version, start, end, platform_arch, connections).await;
all_found_urls_for_version.extend(found);
}
}
let mut latest_urls = get_latest_urls(&all_found_urls_for_version);
latest_urls.insert("version".to_string(), version.clone());
results.push(latest_urls);
}
results
});
let results = search_task.await.unwrap();
pb.finish_and_clear();
if versions.len() == 1 {
let json_output = serde_json::to_string_pretty(&results[0]).unwrap();
println!("{}", json_output);
} else {
let json_output = serde_json::to_string_pretty(&results).unwrap();
println!("{}", json_output);
}
}
fn get_latest_urls(found_urls: &[(String, PlatformArch)]) -> HashMap<String, String> {
let mut platform_urls = HashMap::new();
let version_pattern = Regex::new(r"-(\d+)\.(exe|tbz)$").unwrap();
for (url, platform) in found_urls {
if let Some(captures) = version_pattern.captures(url) {
if let Some(version_number_match) = captures.get(1) {
let version_number = version_number_match.as_str().parse::<u32>().unwrap();
let platform_key = platform.to_string().to_string();
let entry = platform_urls.entry(platform_key).or_insert_with(|| (url.clone(), version_number));
if version_number > entry.1 {
*entry = (url.clone(), version_number);
}
}
}
}
let latest_urls: HashMap<String, String> = platform_urls
.into_iter()
.map(|(k, (v, _))| (k, v))
.collect();
if latest_urls.is_empty() {
let mut empty_map = HashMap::new();
empty_map.insert("unknown".to_string(), "unknown".to_string());
return empty_map;
}
latest_urls
}

4803
LoaderSpot_UI/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,7 @@
[package]
name = "loaderspot_ui"
name = "spotify_finder"
version = "0.1.0"
edition = "2021"
edition = "2025"
[dependencies]
eframe = "0.29"
@ -11,7 +11,4 @@ reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
regex = "1.10"
crossbeam-channel = "0.5"
[target.'cfg(windows)'.dependencies]
winapi = { version = "0.3", features = ["winuser"] }
winapi = { version = "0.3", features = ["winuser"] }

View file

@ -1,13 +1,13 @@
// Hide console window on Windows
#![cfg_attr(target_os = "windows", windows_subsystem = "windows")]
use crossbeam_channel::{unbounded, Receiver, Sender};
use eframe::egui;
use reqwest::Client;
use serde::Deserialize;
use std::collections::{HashMap, VecDeque};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, OnceLock};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{channel, Receiver, Sender};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use tokio::runtime::Runtime;
@ -31,13 +31,13 @@ impl Platform {
}
}
fn generate_path(&self, version: &str, number: i32) -> String {
fn path_template(&self) -> &str {
match self {
Platform::WinX86 => format!("win32-x86/spotify_installer-{version}-{number}.exe"),
Platform::WinX64 => format!("win32-x86_64/spotify_installer-{version}-{number}.exe"),
Platform::WinArm64 => format!("win32-arm64/spotify_installer-{version}-{number}.exe"),
Platform::MacOsIntel => format!("osx-x86_64/spotify-autoupdate-{version}-{number}.tbz"),
Platform::MacOsArm64 => format!("osx-arm64/spotify-autoupdate-{version}-{number}.tbz"),
Platform::WinX86 => "win32-x86/spotify_installer-{version}-{number}.exe",
Platform::WinX64 => "win32-x86_64/spotify_installer-{version}-{number}.exe",
Platform::WinArm64 => "win32-arm64/spotify_installer-{version}-{number}.exe",
Platform::MacOsIntel => "osx-x86_64/spotify-autoupdate-{version}-{number}.tbz",
Platform::MacOsArm64 => "osx-arm64/spotify-autoupdate-{version}-{number}.tbz",
}
}
@ -63,11 +63,11 @@ impl UrlGenerator {
const BASE_URL: &'static str = "https://upgrade.scdn.co/upgrade/client/";
fn generate_url(platform: Platform, version: &str, number: i32) -> String {
format!(
"{}{}",
Self::BASE_URL,
platform.generate_path(version, number)
)
let path = platform
.path_template()
.replace("{version}", version)
.replace("{number}", &number.to_string());
format!("{}{}", Self::BASE_URL, path)
}
}
@ -83,12 +83,15 @@ fn extract_base_version(version: &str) -> String {
#[allow(dead_code)]
fn should_use_win_x86(version: &str) -> bool {
let mut parts = version.split('.');
let base_version = extract_base_version(version);
let parts: Vec<&str> = base_version.split('.').collect();
if let (Some(p1), Some(p2), Some(p3)) = (parts.next(), parts.next(), parts.next()) {
if let (Ok(major), Ok(minor), Ok(patch)) =
(p1.parse::<u32>(), p2.parse::<u32>(), p3.parse::<u32>())
{
if parts.len() >= 3 {
if let (Ok(major), Ok(minor), Ok(patch)) = (
parts[0].parse::<u32>(),
parts[1].parse::<u32>(),
parts[2].parse::<u32>(),
) {
return (major, minor, patch) <= (1, 2, 53);
}
}
@ -96,8 +99,7 @@ fn should_use_win_x86(version: &str) -> bool {
}
fn validate_version(version: &str) -> bool {
static RE: OnceLock<regex::Regex> = OnceLock::new();
let re = RE.get_or_init(|| regex::Regex::new(r"^\d+\.\d+\.\d+\.\d+\.g[0-9a-f]{8}$").unwrap());
let re = regex::Regex::new(r"^\d+\.\d+\.\d+\.\d+\.g[0-9a-f]{8}$").unwrap();
re.is_match(version)
}
@ -115,23 +117,8 @@ const MAX_CONNECTION_OPTIONS: [usize; 6] = [50, 100, 150, 200, 250, 300];
async fn check_url(client: &Client, url: String, platform: Platform) -> Option<(String, Platform)> {
match client.head(&url).send().await {
Ok(response) => {
if response.status().is_success() {
Some((url, platform))
} else if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
eprintln!(
"RATE LIMITED (429)! Server blocked the request for: {}",
url
);
None
} else {
None
}
}
Err(e) => {
eprintln!("Network error: {}", e);
None
}
Ok(response) if response.status().is_success() => Some((url, platform)),
_ => None,
}
}
@ -164,7 +151,7 @@ async fn check_version_and_submit(client: &Client, version: &str) {
let version_exists = versions_json
.values()
.any(|v| v.fullversion.as_ref().is_some_and(|fv| fv == version));
.any(|v| v.fullversion.as_ref().map_or(false, |fv| fv == version));
if !version_exists {
submit_to_google_form(client, version).await;
@ -172,15 +159,16 @@ async fn check_version_and_submit(client: &Client, version: &str) {
}
enum SearchMessage {
Progress(u64, u64),
Result(String, Platform),
Complete(String),
VersionStart(String, usize, usize),
CompleteAll,
}
struct SearchOptions {
client: Client,
version: String,
async fn search_installers(
client: &Client,
version: &str,
start: i32,
end: i32,
platforms: Vec<Platform>,
@ -188,59 +176,45 @@ struct SearchOptions {
tx: Sender<SearchMessage>,
pause_flag: Arc<AtomicBool>,
cancel_flag: Arc<AtomicBool>,
processed: Arc<AtomicU64>,
}
async fn search_installers(opts: SearchOptions) {
) {
use tokio::sync::Semaphore;
let semaphore = Arc::new(Semaphore::new(opts.max_connections));
let total = ((end - start + 1) * platforms.len() as i32) as u64;
let semaphore = Arc::new(Semaphore::new(max_connections));
let mut tasks = Vec::new();
let processed = Arc::new(Mutex::new(0u64));
'outer: for platform in opts.platforms {
for number in opts.start..=opts.end {
if opts.cancel_flag.load(Ordering::Relaxed) {
break 'outer;
}
while opts.pause_flag.load(Ordering::Relaxed) {
if opts.cancel_flag.load(Ordering::Relaxed) {
break 'outer;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
let permit = match semaphore.clone().acquire_owned().await {
Ok(p) => p,
Err(_) => break 'outer,
};
while opts.pause_flag.load(Ordering::Relaxed) {
if opts.cancel_flag.load(Ordering::Relaxed) {
break 'outer;
}
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
}
if opts.cancel_flag.load(Ordering::Relaxed) {
break 'outer;
}
let url = UrlGenerator::generate_url(platform, &opts.version, number);
let client = opts.client.clone();
let tx_clone = opts.tx.clone();
let processed_clone = opts.processed.clone();
let cancel_local = opts.cancel_flag.clone();
for platform in platforms {
for number in start..=end {
let url = UrlGenerator::generate_url(platform, version, number);
let client = client.clone();
let permit = semaphore.clone().acquire_owned().await.unwrap();
let tx_clone = tx.clone();
let processed_clone = processed.clone();
let pause_local = pause_flag.clone();
let cancel_local = cancel_flag.clone();
let task = tokio::spawn(async move {
let _permit = permit;
if cancel_local.load(Ordering::Relaxed) {
return;
}
while pause_local.load(Ordering::Relaxed) {
if cancel_local.load(Ordering::Relaxed) {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(200)).await;
}
let result = check_url(&client, url, platform).await;
processed_clone.fetch_add(1, Ordering::Relaxed);
drop(permit);
let mut p = processed_clone.lock().unwrap();
*p += 1;
let current = *p;
drop(p);
let _ = tx_clone.send(SearchMessage::Progress(current, total));
if let Some((url, platform)) = result {
let _ = tx_clone.send(SearchMessage::Result(url, platform));
@ -251,17 +225,11 @@ async fn search_installers(opts: SearchOptions) {
}
}
if opts.cancel_flag.load(Ordering::Relaxed) {
for task in &tasks {
task.abort();
}
}
for task in tasks {
let _ = task.await;
}
let _ = opts.tx.send(SearchMessage::Complete(opts.version));
let _ = tx.send(SearchMessage::Complete(version.to_string()));
}
struct SpotifyFinderApp {
@ -279,6 +247,7 @@ struct SpotifyFinderApp {
report_unknown: bool,
search_results: String,
is_searching: bool,
is_paused: bool,
displayed_results: String,
@ -293,8 +262,7 @@ struct SpotifyFinderApp {
progress: f32,
progress_text: String,
total_work: u64,
processed_global: Arc<AtomicU64>,
processed_global: u64,
rx: Option<Receiver<SearchMessage>>,
found_urls: HashMap<Platform, Vec<String>>,
@ -316,6 +284,7 @@ impl Default for SpotifyFinderApp {
platform_macos_intel: false,
platform_macos_arm64: false,
report_unknown: false,
search_results: String::new(),
is_searching: false,
is_paused: false,
displayed_results: String::new(),
@ -327,7 +296,7 @@ impl Default for SpotifyFinderApp {
progress: 0.0,
progress_text: String::new(),
total_work: 0,
processed_global: Arc::new(AtomicU64::new(0)),
processed_global: 0,
rx: None,
found_urls: HashMap::new(),
pause_flag: Arc::new(AtomicBool::new(false)),
@ -340,6 +309,7 @@ impl Default for SpotifyFinderApp {
}
impl SpotifyFinderApp {
// Improved reveal logic: finish current item immediately and fast-forward the queue
fn advance_reveal(&mut self) {
if self.current_reveal.is_some() && !self.reveal_queue.is_empty() {
if let Some(cur) = self.current_reveal.take() {
@ -466,6 +436,7 @@ impl SpotifyFinderApp {
self.is_searching = true;
self.progress = 0.0;
self.progress_text = "Starting...".to_string();
self.search_results.clear();
self.displayed_results.clear();
self.reveal_queue.clear();
self.current_reveal = None;
@ -480,15 +451,13 @@ impl SpotifyFinderApp {
let per_version = ((end - start + 1) * cnt as i32) as u64;
total_work_calc = total_work_calc.saturating_add(per_version);
}
self.total_work = total_work_calc;
self.processed_global.store(0, Ordering::Relaxed);
self.processed_global = 0;
self.current_version = None;
self.current_version_index = 0;
self.total_versions = versions.len();
let (tx, rx): (Sender<SearchMessage>, Receiver<SearchMessage>) = unbounded();
let (tx, rx) = channel();
self.rx = Some(rx);
self.pause_flag.store(false, Ordering::Relaxed);
@ -500,7 +469,6 @@ impl SpotifyFinderApp {
let pause = self.pause_flag.clone();
let cancel = self.cancel_flag.clone();
let base_platforms_for_spawn = base_platforms.clone();
let processed_for_spawn = self.processed_global.clone();
self.runtime.spawn(async move {
let client = Client::builder()
@ -534,18 +502,17 @@ impl SpotifyFinderApp {
continue;
}
search_installers(SearchOptions {
client: client.clone(),
version,
search_installers(
&client,
&version,
start,
end,
platforms: platforms_for_version,
max_connections: max_conn,
tx: tx.clone(),
pause_flag: pause.clone(),
cancel_flag: cancel.clone(),
processed: processed_for_spawn.clone(),
})
platforms_for_version,
max_conn,
tx.clone(),
pause.clone(),
cancel.clone(),
)
.await;
if cancel.load(Ordering::Relaxed) {
@ -567,6 +534,7 @@ impl SpotifyFinderApp {
}
fn clear_results(&mut self) {
self.search_results.clear();
self.displayed_results.clear();
self.reveal_queue.clear();
self.current_reveal = None;
@ -574,49 +542,49 @@ impl SpotifyFinderApp {
self.progress = 0.0;
self.progress_text.clear();
self.total_work = 0;
self.processed_global.store(0, Ordering::Relaxed);
self.processed_global = 0;
}
fn update_search_progress(&mut self) {
let current_processed = self.processed_global.load(Ordering::Relaxed);
let denom = if self.total_work == 0 {
1
} else {
self.total_work
};
self.progress = current_processed as f32 / denom as f32;
if let Some(v) = &self.current_version {
let short = short_version(v);
if self.total_versions > 1 {
self.progress_text = format!(
"Checking: {}/{}, Version: {}, No. {}/{}",
current_processed,
denom,
short,
self.current_version_index,
self.total_versions
);
} else {
self.progress_text = format!(
"Checking: {}/{}, Version: {}",
current_processed, denom, short
);
}
} else if self.is_searching {
self.progress_text = format!("Checking: {}/{}", current_processed, denom);
}
if let Some(rx_owned) = self.rx.take() {
let rx = rx_owned;
let mut completed = false;
let mut processed_this_frame = 0;
while let Ok(msg) = rx_owned.try_recv() {
processed_this_frame += 1;
while let Ok(msg) = rx.try_recv() {
match msg {
SearchMessage::Progress(_current, _total) => {
self.processed_global = self.processed_global.saturating_add(1);
let denom = if self.total_work == 0 {
1
} else {
self.total_work
};
self.progress = self.processed_global as f32 / denom as f32;
if let Some(v) = &self.current_version {
let short = short_version(v);
if self.total_versions > 1 {
self.progress_text = format!(
"Checking: {}/{}, Version: {}, No. {}/{}",
self.processed_global,
denom,
short,
self.current_version_index,
self.total_versions
);
} else {
self.progress_text = format!(
"Checking: {}/{}, Version: {}",
self.processed_global, denom, short
);
}
} else {
self.progress_text =
format!("Checking: {}/{}", self.processed_global, denom);
}
}
SearchMessage::Result(url, platform) => {
let entry = self.found_urls.entry(platform).or_default();
let entry = self.found_urls.entry(platform).or_insert_with(Vec::new);
let first = entry.is_empty();
entry.push(url.clone());
@ -658,14 +626,10 @@ impl SpotifyFinderApp {
completed = true;
}
}
if processed_this_frame > 1000 && !completed {
break;
}
}
if !completed {
self.rx = Some(rx_owned);
self.rx = Some(rx);
}
}
}
@ -990,13 +954,15 @@ impl eframe::App for SpotifyFinderApp {
self.is_paused = false;
self.pause_flag.store(false, Ordering::Relaxed);
}
} else if ui
.add_sized(btn_size, egui::Button::new("⏸ Pause"))
.clicked()
{
self.is_paused = true;
self.pause_flag.store(true, Ordering::Relaxed);
self.progress_text = "Paused".to_string();
} else {
if ui
.add_sized(btn_size, egui::Button::new("⏸ Pause"))
.clicked()
{
self.is_paused = true;
self.pause_flag.store(true, Ordering::Relaxed);
self.progress_text = "Paused".to_string();
}
}
if ui
@ -1005,11 +971,13 @@ impl eframe::App for SpotifyFinderApp {
{
self.stop_search();
}
} else if ui
.add_sized(btn_size, egui::Button::new("▶ Start Search"))
.clicked()
{
self.start_search();
} else {
if ui
.add_sized(btn_size, egui::Button::new("▶ Start Search"))
.clicked()
{
self.start_search();
}
}
ui.add_enabled_ui(!self.is_searching, |ui| {

View file

@ -1,125 +0,0 @@
param (
[string]$versions,
[string]$source,
[string]$googleAppsUrl
)
function Find-BuildInfo {
[CmdletBinding()]
param (
[Parameter(Mandatory=$true, ValueFromPipeline=$true, Position=0)]
[string]$Path
)
process {
try {
$fileContent = Get-Content -Path $Path -ErrorAction Stop
$found = $false
$regex = '(Master|Release|PR|Local) Build.+(?:cef_)?(\d+\.\d+\.\d+\+g[0-9a-f]+\+chromium-\d+\.\d+\.\d+\.\d+)'
foreach ($line in $fileContent) {
$match = [regex]::Match($line, $regex)
if ($match.Success) {
$buildType = $match.Groups[1].Value
Write-Host "build type: $buildType"
return $buildType
}
}
if (-not $found) {
Write-Host "Билд не найден"
return $false
}
}
catch {
Write-Error "Ошибка при чтении файла: $_"
return $false
}
}
}
function Get-BuildTypeFromUrl {
[CmdletBinding()]
param(
[string]$Url
)
$tempPath = Join-Path ([System.IO.Path]::GetTempPath()) "spotify"
$destinationPath = Join-Path $tempPath "unpacked"
$exePath = Join-Path $tempPath "SpotifySetup.exe"
if (-not (Test-Path -Path $tempPath)) {
New-Item -ItemType Directory -Path $tempPath | Out-Null
}
if (-not (Test-Path -Path $destinationPath)) {
New-Item -ItemType Directory -Path $destinationPath | Out-Null
}
try {
Write-Host "Скачивание файла из $Url..."
Invoke-WebRequest -Uri $Url -OutFile $exePath
Write-Host "Файл сохранен в $exePath"
Write-Host "Распаковка файла в $destinationPath"
Start-Process -Wait -FilePath $exePath -ArgumentList "/extract `"$destinationPath`""
$dllPath = Join-Path $destinationPath "Spotify.dll"
$exePathForAnalysis = Join-Path $destinationPath "Spotify.exe"
if (Test-Path $dllPath) {
return Find-BuildInfo -Path $dllPath
} elseif (Test-Path $exePathForAnalysis) {
return Find-BuildInfo -Path $exePathForAnalysis
}
else {
Write-Error "Файлы Spotify.dll и Spotify.exe не найдены в $destinationPath"
return $false
}
}
catch {
Write-Error "Произошла ошибка при скачивании или распаковке: $_"
return $false
}
}
if ([string]::IsNullOrEmpty($versions) -or [string]::IsNullOrEmpty($source) -or [string]::IsNullOrEmpty($googleAppsUrl)) {
Write-Error "Один или несколько обязательных параметров (versions, source, googleAppsUrl) не предоставлены."
exit 1
}
$versionsObj = $versions | ConvertFrom-Json
$win64Url = $versionsObj.WIN64
$buildType = $false
if ($win64Url) {
$buildType = Get-BuildTypeFromUrl -Url $win64Url
}
if ($buildType -eq $false) {
$versionsObj | Add-Member -NotePropertyName "buildType" -NotePropertyValue $false -Force
} else {
$versionsObj | Add-Member -NotePropertyName "buildType" -NotePropertyValue $buildType -Force
}
$versionsObj | Add-Member -NotePropertyName "source" -NotePropertyValue $source -Force
$finalJson = $versionsObj | ConvertTo-Json -Compress
Write-Host "Отправка данных на GAS..."
try {
$response = Invoke-WebRequest -Uri $googleAppsUrl `
-Method POST `
-ContentType "application/json" `
-Body $finalJson `
-UseBasicParsing -ErrorAction Stop
if ($response.StatusCode -eq 200) {
Write-Host "Ответ от GAS: $($response.Content)"
} else {
Write-Error "Ошибка при отправке в GAS. Статус: $($response.StatusCode). Ответ: $($response.Content)"
}
} catch {
Write-Error "Критическая ошибка при отправке в GAS: $_"
}

View file

@ -123,9 +123,9 @@ def commit_changes(new_versions):
# Формируем сообщение коммита в зависимости от количества новых версий
if new_versions:
commit_message = (
f"Added deb version {new_versions[0][1]}"
f"Added deb version {new_versions[0][0]}"
if len(new_versions) == 1
else f"Added deb versions: {', '.join(v[1] for v in new_versions)}"
else f"Added deb versions: {', '.join(v[0] for v in new_versions)}"
)
else:
commit_message = "Update Spotify versions"

163
upd.py Normal file
View file

@ -0,0 +1,163 @@
import aiohttp
import asyncio
import json
import re
import argparse
from bs4 import BeautifulSoup
parser = argparse.ArgumentParser()
parser.add_argument("-v", required=True)
parser.add_argument("-s", required=True)
parser.add_argument("-u", required=True)
args = parser.parse_args()
version = args.v
source = args.s
u = args.u
async def send_request(json_data):
url = u + json_data
async with aiohttp.ClientSession() as session:
async with session.get(url) as response:
if response.status == 200:
data = await response.text()
if "<div" in data:
soup = BeautifulSoup(data, "html.parser")
div_element = soup.find(
"div",
style="text-align:center;font-family:monospace;margin:50px auto 0;max-width:600px",
)
if div_element:
system_response = div_element.text
else:
system_response = "Не удалось извлечь ответ из HTML"
else:
system_response = data.strip()
print(f"Ответ от GAS: {system_response}")
async def check_url(session, url, platform_name):
try:
async with session.get(url) as response:
if response.status == 200:
if platform_name:
return response.url, platform_name
else:
return response.url
except aiohttp.ClientError:
pass
return None
def parse_version(ver_str):
parts = ver_str.split(".")[:4]
return tuple(int(x) for x in parts)
async def pre_version(latest_urls):
message = f'Версия {version} {"отправлена" if latest_urls else "не найдена"}'
print(message)
if not latest_urls:
latest_urls = {"unknown": "unknown", "version": version}
latest_urls["version"] = version
latest_urls["source"] = source
req_ver = json.dumps(latest_urls, ensure_ascii=False, indent=2)
await send_request(req_ver)
def get_urls(find_url):
platform_urls = {}
version_pattern = re.compile(r"-([0-9]+)\.(exe|tbz)")
for url, platform in find_url:
match = version_pattern.search(str(url))
if match:
version_number = int(match.group(1))
if platform in platform_urls:
# Если найдено больше одного url для платформы, то выбираем с самой большой ревизией
current_version = int(
version_pattern.search(platform_urls[platform]).group(1)
)
if version_number > current_version:
platform_urls[platform] = str(url)
else:
platform_urls[platform] = str(url)
return platform_urls
async def main():
start_number = 0
before_enter = 1000
additional_searches = 10 # Максимальное кол-во шагов для дополнительного поиска
increment = 1000 # Размер шага
find_url = []
root_url = "https://upgrade.scdn.co/upgrade/client/"
platform_templates = {
"WIN32": "win32-x86/spotify_installer-{version}-{numbers}.exe",
"WIN64": "win32-x86_64/spotify_installer-{version}-{numbers}.exe",
"WIN-ARM64": "win32-arm64/spotify_installer-{version}-{numbers}.exe",
"OSX": "osx-x86_64/spotify-autoupdate-{version}-{numbers}.tbz",
"OSX-ARM64": "osx-arm64/spotify-autoupdate-{version}-{numbers}.tbz",
}
# не ищем архитектуру WIN32 если версия >= 1.2.54.304
if parse_version(version) >= (1, 2, 54, 304):
platform_templates.pop("WIN32", None)
async with aiohttp.ClientSession() as session:
tasks = []
platform_names = list(platform_templates.keys())
for platform_name in platform_names:
numbers = start_number
while numbers <= before_enter:
url = root_url + platform_templates[platform_name].format(
version=version, numbers=numbers
)
tasks.append(check_url(session, url, platform_name))
numbers += 1
for task in asyncio.as_completed(tasks):
result = await task
if result is not None:
find_url.append(result)
for i in range(additional_searches):
latest_urls = get_urls(find_url)
if len(latest_urls) < len(platform_names):
start_number = before_enter + 1
before_enter += increment
tasks = []
for platform_name in platform_names:
if platform_name not in latest_urls:
numbers = start_number
while numbers <= before_enter:
url = root_url + platform_templates[platform_name].format(
version=version, numbers=numbers
)
tasks.append(check_url(session, url, platform_name))
numbers += 1
if tasks:
for task in asyncio.as_completed(tasks):
result = await task
if result is not None:
find_url.append(result)
if find_url:
latest_urls = get_urls(find_url)
await pre_version(latest_urls)
else:
await pre_version(False)
if __name__ == "__main__":
asyncio.run(main())

View file

@ -1,63 +0,0 @@
#!/usr/bin/env python3
import json
import re
import sys
def update_version(data_json: str, build_type: str = None, version_file: str = "versions.json") -> bool:
try:
data = json.loads(data_json)
version_key = list(data.keys())[0]
version_data = data[version_key]
if build_type and build_type.lower() != 'false':
version_data = {'buildType': build_type, **version_data}
with open(version_file, 'r', encoding='utf-8') as f:
file_data = json.load(f)
file_data[version_key] = version_data
sorted_data = dict(
sorted(
file_data.items(),
key=lambda x: [
int(part) if part.isdigit() else part
for part in re.split(r'(\d+)', x[0])
],
reverse=True
)
)
with open(version_file, 'w', encoding='utf-8') as f:
json.dump(sorted_data, f, indent=2)
print(f"Version {version_key} added to {version_file}")
return True
except json.JSONDecodeError as e:
print(f"JSON parse error: {e}", file=sys.stderr)
return False
except FileNotFoundError:
print(f"File {version_file} not found", file=sys.stderr)
return False
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
return False
def main():
if len(sys.argv) < 2:
print("Usage: python update_version.py '<json_data>' [build_type] [version_file]", file=sys.stderr)
sys.exit(1)
data_json = sys.argv[1]
build_type = sys.argv[2] if len(sys.argv) > 2 else None
version_file = sys.argv[3] if len(sys.argv) > 3 else "versions.json"
success = update_version(data_json, build_type, version_file)
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

File diff suppressed because it is too large Load diff

View file

@ -1,44 +1,4 @@
{
"1.2.84.476": {
"fullversion": "1.2.84.476.ga1ff6607",
"size": "157643240",
"data": "09.03.2026",
"links": {
"amd64": "https://github.com/LoaderSpot/deb-builds/releases/download/builds/spotify-client_1.2.84.476.ga1ff6607_amd64.deb"
}
},
"1.2.82.428": {
"fullversion": "1.2.82.428.g0ac8be2b",
"size": "156375346",
"data": "09.02.2026",
"links": {
"amd64": "https://github.com/LoaderSpot/deb-builds/releases/download/builds/spotify-client_1.2.82.428.g0ac8be2b_amd64.deb"
}
},
"1.2.79.427": {
"fullversion": "1.2.79.427.g80eb4a07",
"size": "155456648",
"data": "12.01.2026",
"links": {
"amd64": "https://github.com/LoaderSpot/deb-builds/releases/download/builds/spotify-client_1.2.79.427.g80eb4a07_amd64.deb"
}
},
"1.2.79.425": {
"fullversion": "1.2.79.425.g1d0fcf61",
"size": "155381166",
"data": "17.12.2025",
"links": {
"amd64": "https://github.com/LoaderSpot/deb-builds/releases/download/builds/spotify-client_1.2.79.425.g1d0fcf61_amd64.deb"
}
},
"1.2.77.358": {
"fullversion": "1.2.77.358.g4339a634",
"size": "155510286",
"data": "25.11.2025",
"links": {
"amd64": "https://github.com/LoaderSpot/deb-builds/releases/download/builds/spotify-client_1.2.77.358.g4339a634_amd64.deb"
}
},
"1.2.74.477": {
"fullversion": "1.2.74.477.g3be53afe",
"size": "154653044",