From 3428da3a78244b5edef671839126fda5a4990655 Mon Sep 17 00:00:00 2001 From: Rafiuth <53208252+Rafiuth@users.noreply.github.com> Date: Fri, 8 Dec 2023 18:59:43 -0300 Subject: [PATCH] New install approach, misc fixes - Persist on main Spotify install and %localappdata% for config - DLL hijacking instead of injection - Fix Liked Songs playlist bricking BlockTheSpot also targets dpapi.dll for hijacking, so Soggfy won't be compatible with it. dpapi is the easiest target because it doesn't need export redirection (at least of yet). netmsg.dll may be another possible target, but I'm not sure it's quite reliable since it seems to be loaded exclusively for some error message formatting stuff. --- .github/workflows/release.yml | 3 +- Injector/Injector.cpp | 26 +---- README.md | 26 ++--- SpotifyOggDumper/CefUtils.cpp | 33 ++---- SpotifyOggDumper/CefUtils.h | 5 +- SpotifyOggDumper/ControlServer.cpp | 23 +++-- SpotifyOggDumper/ControlServer.h | 9 +- SpotifyOggDumper/Data/Install.ps1 | 140 ++++++++++++-------------- SpotifyOggDumper/Data/Uninstall.cmd | 5 + SpotifyOggDumper/Main.cpp | 42 +++----- SpotifyOggDumper/StateManager.cpp | 49 +++++---- SpotifyOggDumper/StateManager.h | 2 +- SpotifyOggDumper/Utils/Utils.cpp | 4 + SpotifyOggDumper/Utils/Utils.h | 21 +++- Sprinkles/dev_build.bat | 3 +- Sprinkles/src/player-state-tracker.ts | 16 +-- Sprinkles/src/ui/status-indicator.ts | 11 ++ Sprinkles/src/ui/ui.ts | 20 ++-- Sprinkles/webpack.config.js | 2 +- USAGE.md | 30 ------ fetch_external_deps.bat | 2 +- 21 files changed, 220 insertions(+), 252 deletions(-) create mode 100644 SpotifyOggDumper/Data/Uninstall.cmd delete mode 100644 USAGE.md diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5f5a03c..be04ab4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -35,8 +35,7 @@ jobs: npm install npm run build - Copy-Item dist/bundle.js ../build/Release/Soggfy.js - Copy-Item dist/bundle.js.map ../build/Release/Soggfy.js.map + Copy-Item dist/*.* ../build/Release/ cd ../ - name: Create Zip diff --git a/Injector/Injector.cpp b/Injector/Injector.cpp index 151c525..2371e97 100644 --- a/Injector/Injector.cpp +++ b/Injector/Injector.cpp @@ -181,20 +181,7 @@ void DeleteSpotifyUpdate() } CoTaskMemFree(localAppData); } -bool IsFFmpegInstalled() -{ - //Try Soggfy/ffmpeg/ffmpeg.exe - if (fs::exists("ffmpeg/ffmpeg.exe")) { - return true; - } - //Try %PATH% - std::wstring envPath; - DWORD envPathLen = SearchPath(NULL, L"ffmpeg.exe", NULL, 0, envPath.data(), NULL); - if (envPathLen != 0) { - return true; - } - return false; -} + void EnableAnsiColoring() { HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE); @@ -205,10 +192,10 @@ void EnableAnsiColoring() int main(int argc, char* argv[]) { - bool attach = false; + bool launch = false; bool enableRemoteDebug = false; for (int i = 0; i < argc; i++) { - attach |= strcmp(argv[i], "-a") == 0; + launch |= strcmp(argv[i], "-l") == 0; enableRemoteDebug |= strcmp(argv[i], "-d") == 0; } @@ -216,7 +203,7 @@ int main(int argc, char* argv[]) try { HANDLE targetProc; - if (attach) { + if (!launch) { targetProc = FindSpotifyProcess(); } else { KillSpotifyProcesses(); @@ -229,11 +216,6 @@ int main(int argc, char* argv[]) CloseHandle(targetProc); std::cout << COL_GREEN "Injection succeeded!\n" COL_RESET; - - if (!IsFFmpegInstalled()) { - std::cout << COL_YELLOW "Note: FFmpeg binaries were not found, songs won't be tagged nor converted.\n"; - std::cout << COL_YELLOW "Run Install.ps1 or add ffmpeg to the PATH environment variable.\n" COL_RESET; - } } catch (std::exception& ex) { std::cout << COL_RED "Error: " << ex.what() << "\n" COL_RESET; } diff --git a/README.md b/README.md index fd0282a..25b0115 100644 --- a/README.md +++ b/README.md @@ -16,32 +16,34 @@ A music downloader mod for the Windows Spotify client # Installation and Usage 1. Download and extract the `.zip` package of the [latest release](https://github.com/Rafiuth/Soggfy/releases/latest). 2. Double click the `Install.cmd` file. It will run the Install.ps1 script with Execution Policy Bypass. Wait for it to finish. -3. Run `Injector.exe`, and wait for Spotify to open. -4. Play the songs you want to download. +3. Open Spotify and play the songs you want to download. -Tracks are saved on the Music folder by default, this can be changed on the settings pane, which can be accessed through the controls button shown after hovering the download toggle button in the navigation bar. +Tracks are saved in the Music folder by default. The settings panel can be accessed by hovering next to the download button in the navigation bar. Hovering the check mark drawn on each individual track will display a popup offering to open the folder containing them. You may need to disable or whitelist Soggfy in your anti-virus for it to work. -If the injector crashes because of missing DLLs, you need to install the [MSVC Redistributable package](https://aka.ms/vs/17/release/vc_redist.x86.exe). +If the Spotify client crashes because of missing DLLs, you may need to install the [MSVC Redistributable package](https://aka.ms/vs/17/release/vc_redist.x86.exe). # Notes - Songs are only downloaded if played from start to finish, without seeking (pausing is fine). - Quality depends on the account being used: _160Kb/s_ or _320Kb/s_ for _free_ and _premium_ plans respectively. You may also need to change the streaming quality to "Very high" on Spotify settings to get _320Kb/s_ files. -- If you are _converting_ to AAC and care about quality, see [High quality AAC](/USAGE.md#high-quality-aac). -- **This tool breaks [Spotify's Guidelines](https://www.spotify.com/us/legal/user-guidelines/) and using it could get your account banned. Consider using alt accounts or keeping backups (see [Exportify](https://github.com/watsonbox/exportify) and [SpotMyBackup](http://www.spotmybackup.com)).** +- Podcast support is very hit or miss and will only work with audio-only OGG podcasts (usually the exclusive ones). +- **This mod breaks [Spotify's Guidelines](https://www.spotify.com/us/legal/user-guidelines/) and using it could get your account banned. Consider using alt accounts or keeping backups (see [Exportify](https://github.com/watsonbox/exportify) and [SpotMyBackup](http://www.spotmybackup.com)).** # How it works -Soggfy works by intercepting Spotify's OGG parser and capturing the unencr​ypted data during playback. This process is similar to recording, but it results in an exact copy of the original file served by Spotify, without ever extracting k​eys or actually re-downloading it. +Soggfy works by intercepting Spotify's OGG parser and capturing the unencrypted data during playback. This process is similar to recording, but it results in an exact copy of the original files served by Spotify, without ever extracting keys or actually re-downloading them. Conversion and metadata is then applied according to user settings. -# Manual Install +# Manual Installation If you are having issues with the install script, try following the steps below for a manual installation: -1. Download and install the _correct_ Spotify client version using the link inside the install script. -2. Go to `%appdata%` and copy the `Spotify` folder to the `Soggfy` folder (such that the final structure looks like `Soggfy/Spotify/Spotify.exe`). -_Note that you can also install other mods such as Spicetify or SpotX before this step._ -3. [Download and install FFmpeg](/USAGE.md#high-quality-aac). + +1. Download and install the _correct_ Spotify client version using the link inside the Install.ps1 script. +2. Copy and rename `SpotifyOggDumper.dll` to `%appdata%/Spotify/dpapi.dll` +3. Copy `SoggfyUIC.js` to `%appdata%/Spotify/SoggfyUIC.js` +4. Download and extract [FFmpeg binaries](https://github.com/AnimMouse/ffmpeg-autobuild/releases) to `%localappdata%/Soggfy/ffmpeg/ffmpeg.exe` (or add them to `%PATH%`). + +Alternatively, Soggfy can be injected into a running Spotify process by running `Injector.exe`. # Credits - [XSpotify](https://web.archive.org/web/20200303145624/https://github.com/meik97/XSpotify) and spotifykeydumper - Inspiration for this project diff --git a/SpotifyOggDumper/CefUtils.cpp b/SpotifyOggDumper/CefUtils.cpp index 10e315e..3044f1f 100644 --- a/SpotifyOggDumper/CefUtils.cpp +++ b/SpotifyOggDumper/CefUtils.cpp @@ -6,14 +6,6 @@ #include -//Compat stuff (these got removed in C++20) -namespace std -{ - template struct result_of; - template - struct result_of : std::invoke_result {}; -} - //hack to prevent linking to cef library #ifndef NDEBUG #define NDEBUG @@ -41,9 +33,7 @@ namespace std //Because of the API incompat: trying to free CefString returned by an API will result in a crash. //This workaround will obviously leak memory, but it's small and rare enough that it isn't a big deal. -#define CEF_STRING_ABANDON(str) \ - str.clear(); \ - *(bool*)((void**)&str + 2) = false; //url.owner_ = false +#define CEF_STRING_ABANDON(str) url.Detach() struct _CefBrowserInfo; typedef void* _CefLock; //ABI: pthreads=size_t(4/8) vs msvc=CRITICAL_SECTION(24/40) @@ -199,21 +189,16 @@ namespace CefUtils } } - typedef cef_urlrequest_t* (*cef_urlrequest_create_proc)( - _cef_request_t* request, - _cef_urlrequest_client_t* client, - _cef_request_context_t* request_context - ); - cef_urlrequest_create_proc UrlRequestCreate_Orig; std::function _isUrlBlocked; - cef_urlrequest_t* UrlRequestCreate_Detour( + DETOUR_FUNC(__cdecl, cef_urlrequest_t*, UrlRequestCreate, ( _cef_request_t* request, _cef_urlrequest_client_t* client, - _cef_request_context_t* request_context) + _cef_request_context_t* request_context + )) { auto url = request->get_url(request); - auto urlsv = std::wstring_view(url->str, url->length); + auto urlsv = std::wstring_view((wchar_t*)url->str, url->length); bool blocked = _isUrlBlocked(urlsv); if (LogMinLevel <= LOG_TRACE) { @@ -222,12 +207,10 @@ namespace CefUtils cef_string_userfree_free(url); return blocked ? nullptr : UrlRequestCreate_Orig(request, client, request_context); } - std::pair InitUrlBlocker(std::function isUrlBlocked) + + void InitUrlBlocker(std::function isUrlBlocked) { _isUrlBlocked = isUrlBlocked; - return std::make_pair( - (OpaqueFn)&UrlRequestCreate_Detour, - (OpaqueFn*)&UrlRequestCreate_Orig - ); + Hooks::CreateApi(L"libcef.dll", "cef_urlrequest_create", &UrlRequestCreate_Detour, (Hooks::FuncAddr*)&UrlRequestCreate_Orig); } } \ No newline at end of file diff --git a/SpotifyOggDumper/CefUtils.h b/SpotifyOggDumper/CefUtils.h index 851ed3b..b10c75b 100644 --- a/SpotifyOggDumper/CefUtils.h +++ b/SpotifyOggDumper/CefUtils.h @@ -7,7 +7,6 @@ namespace CefUtils { void InjectJS(const std::string& code); - typedef void* OpaqueFn; - //Initializes the URL blocker and returns a pair of for cef_urlrequest_create() hook. - std::pair InitUrlBlocker(std::function isUrlBlocked); + //Initializes the URL blocker hook. + void InitUrlBlocker(std::function isUrlBlocked); } \ No newline at end of file diff --git a/SpotifyOggDumper/ControlServer.cpp b/SpotifyOggDumper/ControlServer.cpp index 9448414..ced2578 100644 --- a/SpotifyOggDumper/ControlServer.cpp +++ b/SpotifyOggDumper/ControlServer.cpp @@ -10,6 +10,7 @@ void ControlServer::Run() { _loop = uWS::Loop::get(); _app = std::make_unique(); + CreateIdleTimer(); _app->ws("/sgf_ctrl", { .compression = uWS::CompressOptions::DISABLED, @@ -54,20 +55,12 @@ void ControlServer::Run() ws->getUserData()->Socket = nullptr; } }); -#if _DEBUG - _app->get("/", [](auto res, auto req) { - res->end("Soggfy it's working ;)"); - }); -#endif int attemptNum = 0; std::function listenHandler = [&](auto socket) { if (socket) { _socket = socket; LogInfo("Control server listening on port {}", _port); - - std::string addr = "ws://127.0.0.1:" + std::to_string(_port) + "/sgf_ctrl"; - _msgHandler(nullptr, { MessageType::SERVER_OPEN, { { "addr", addr } } }); } else { if (attemptNum > 64) { throw std::exception("Failed to open control server."); @@ -94,6 +87,7 @@ void ControlServer::Stop() for (auto ws : clients) { ws->end(1001, "Server is shutting down"); } + us_timer_close(_idleTimer); us_listen_socket_close(0, _socket); _socket = nullptr; }); @@ -102,6 +96,19 @@ void ControlServer::Stop() } } +void ControlServer::CreateIdleTimer() +{ + _idleTimer = us_create_timer((us_loop_t*)_loop, 1, sizeof(ControlServer*)); + *(ControlServer**)us_timer_ext(_idleTimer) = this; + + us_timer_set(_idleTimer, [](us_timer_t* timer) { + auto sv = *(ControlServer**)us_timer_ext(timer); + if (sv->_clients.size() == 0) { + sv->_msgHandler(nullptr, { MessageType::IDLE }); + } + }, 1000, 1000); +} + void SendData(WebSocket* socket, const std::string& data, uWS::OpCode opcode = uWS::BINARY) { if (socket->send(data, opcode) != WebSocket::SendStatus::SUCCESS) { diff --git a/SpotifyOggDumper/ControlServer.h b/SpotifyOggDumper/ControlServer.h index d4332c1..57a89cc 100644 --- a/SpotifyOggDumper/ControlServer.h +++ b/SpotifyOggDumper/ControlServer.h @@ -21,7 +21,7 @@ enum class MessageType //Internal HELLO = -1, //Client connected BYE = -2, //Client disconnected - SERVER_OPEN = -128 //Server listen success, message: { addr: string } + IDLE = -3, //Sent periodicaly when there are no clients connected }; struct Message @@ -87,11 +87,14 @@ class ControlServer //Sends the specified message to all connected clients. Can be called from any thread. void Broadcast(const Message& msg); void Broadcast(MessageType type, const json& content) { Broadcast({ type, content }); } - + + int GetListenPort() const { return _port; } + private: std::unique_ptr _app; uWS::Loop* _loop = nullptr; us_listen_socket_t* _socket = nullptr; + us_timer_t* _idleTimer = nullptr; std::unordered_set _clients; std::condition_variable _doneCond; @@ -99,6 +102,8 @@ class ControlServer int _port = 28653; MessageHandler _msgHandler; + + void CreateIdleTimer(); }; struct Connection diff --git a/SpotifyOggDumper/Data/Install.ps1 b/SpotifyOggDumper/Data/Install.ps1 index 1c110b0..5393c3c 100644 --- a/SpotifyOggDumper/Data/Install.ps1 +++ b/SpotifyOggDumper/Data/Install.ps1 @@ -1,86 +1,81 @@ $base = $PWD.Path -$SpotifyInstallerUrl = "https://upgrade.scdn.co/upgrade/client/win32-x86/spotify_installer-1.2.25.1011.g0348b2ea-701.exe" +$temp = "$base\temp"; +$SpotifyDir = "$env:APPDATA\Spotify" + +$SpotifyInstallerUrl = "https://upgrade.scdn.co/upgrade/client/win32-x86/spotify_installer-1.2.26.1187.g36b715a1-269.exe" $SpotifyVersion = $SpotifyInstallerUrl -replace '.+installer-(.+)\.g.+', '$1' +$SpotifyVersionWithCommit = $SpotifyInstallerUrl -replace '.+installer-(.+\.g.+)\.exe', '$1' Set-Location -Path "$base\" -function InstallSpotify { - DownloadFile -Url $SpotifyInstallerUrl -DestPath "$base\SpotifyInstaller-$SpotifyVersion.exe" +function CheckOrInstallSpotify { + if (Test-Path "$SpotifyDir\Spotify.exe") { + $installedVersion = (Get-Item "$SpotifyDir\Spotify.exe").VersionInfo.FileVersion; - Write-Host "Extracting..." - Start-Process -FilePath "$base\SpotifyInstaller-$SpotifyVersion.exe" -ArgumentList "/extract" -Wait - Remove-Item "$base\SpotifyInstaller-$SpotifyVersion.exe" - - if (Test-Path "$base\Spotify/") { - Remove-Item "$base\Spotify" -Force -Recurse - } - - $spotifyFolder = Get-ChildItem -Filter ".\spotify-update-*" - Rename-Item -Path $spotifyFolder.FullName -NewName "$base\Spotify" + if ($installedVersion -ne $SpotifyVersion) { + Write-Host "The currently installed Spotify version $installedVersion may not work with this version of Soggfy." - if ((Read-Host -Prompt "Do you want to install SpotX to block ads and enable extra client features? Y/N") -eq "y") { - InstallSpotX + if ((Read-Host -Prompt "Do you want to reinstall to the recommended version ($SpotifyVersion)? Y/N") -ne "y") { return; } + } + else { + return; + } + + Stop-Process -Name "Spotify" -ErrorAction SilentlyContinue } - Remove-Item -Path "$base\Spotify\crash_reporter.cfg" -} -function InstallSpotX { - $baseUrl = "https://raw.githubusercontent.com/SpotX-Official/SpotX/5f85bf124a1f459b4016d775e4219b8ebdf135fa" - $src = (Invoke-WebRequest "$baseUrl/run.ps1" -UseBasicParsing).Content + DownloadFile -Url $SpotifyInstallerUrl -DestPath "$temp\SpotifyInstaller-$SpotifyVersion.exe" - # Patch the script so it runs on our install dir, and at a fixed commit so that it hopefully won't break easily - $src = $src.Replace('Join-Path $env:APPDATA ''Spotify', "Join-Path '$base\Spotify' '"); - $src = $src.Replace('https://spotx-official.github.io/SpotX', $baseUrl); - $src = $src.Replace('[System.Text.Encoding]::UTF8.GetString($response)', '$response'); + Write-Host "Installing..." - # Set-Content -Path "spotx_patched.ps1" -Value $src - Invoke-Expression "&{ $src } -new_theme -block_update_on" -} + # Remove everything but user folders, to avoid conflicts with Spicetify extracted files + Remove-Item -Path $SpotifyDir -Recurse -Exclude ("Users\", "prefs") -ErrorAction SilentlyContinue + + # Other undocumented switches: /extract /log-file + Start-Process -FilePath "$temp\SpotifyInstaller-$SpotifyVersion.exe" -ArgumentList "/silent /skip-app-launch" -Wait + Remove-Item -Path "$SpotifyDir\crash_reporter.cfg" -ErrorAction SilentlyContinue + if ((Read-Host -Prompt "Do you want to install SpotX to block ads, updates, and enable extra client features? Y/N") -eq "y") { + $src = (Invoke-WebRequest "https://spotx-official.github.io/run.ps1" -UseBasicParsing).Content + $src = [System.Text.Encoding]::UTF8.GetString($src); + Invoke-Expression "& { $src } -new_theme -block_update_on -version $SpotifyVersionWithCommit" + } +} function InstallFFmpeg { - if ([Environment]::Is64BitOperatingSystem) { - $repoUrl = "https://api.github.com/repos/BtbN/FFmpeg-Builds/releases/latest" - $platform = "win64" - } else { - $repoUrl = "https://api.github.com/repos/sudo-nautilus/FFmpeg-Builds-Win32/releases/latest" - $platform = "win32" + where.exe /q ffmpeg + if ($LastExitCode -eq 0) { + Write-Host "Will use FFmpeg binaries found in %PATH% at '$(where.exe ffmpeg)'." + return; } - $release = Invoke-WebRequest $repoUrl -UseBasicParsing | ConvertFrom-Json - - foreach ($asset in $release.assets) { - if ($asset.name -cmatch "ffmpeg-n.+$platform-gpl-shared") { - DownloadFile -Url $asset.browser_download_url -DestPath "$base\$($asset.name)" - - # Remove previous installation - if (Test-Path "$base\ffmpeg\") { - Remove-Item -Path "$base\ffmpeg\" -Recurse -Force - } - New-Item -ItemType Directory -Path "$base\ffmpeg\" -Force | Out-Null - - # Extract bin/ folder to ffmpeg/ - Add-Type -Assembly System.IO.Compression.FileSystem - $zip = [IO.Compression.ZipFile]::OpenRead("$base\$($asset.name)") - - foreach ($entry in $zip.Entries) { - if ($entry.FullName -notlike "*/bin/*.*") { continue; } - [IO.Compression.ZipFileExtensions]::ExtractToFile($entry, "$base\ffmpeg\" + $entry.Name) - } - $zip.Dispose() - - # Delete zip - Remove-Item "$base\$($asset.name)" - - return - } + if ((Test-Path "$env:LOCALAPPDATA\Soggfy\ffmpeg\ffmpeg.exe")) { + if ((Read-Host -Prompt "Do you want to re-install or update FFmpeg? Y/N") -ne "y") { return; } + + Remove-Item -Path "$env:LOCALAPPDATA\Soggfy\ffmpeg\" -Recurse -Force } + $arch = $(if ([Environment]::Is64BitOperatingSystem) { "win64" } else { "win32" }) + $release = Invoke-WebRequest "https://api.github.com/repos/AnimMouse/ffmpeg-autobuild/releases/latest" -UseBasicParsing | ConvertFrom-Json + $asset = $release.assets | Where-Object { $_.name.Contains($arch) } | Select-Object -First 1 + + DownloadFile -Url $asset.browser_download_url -DestPath "$temp/$($asset.name)" + DownloadFile -Url "https://7-zip.org/a/7zr.exe" -DestPath "$temp/7zr.exe" - Write-Host "Failed to install ffmpeg. Try downloading it from https://ffmpeg.org/download.html and extract the binaries into the 'Soggfy/ffmpeg/' directory." + & "$temp\7zr.exe" e "$temp\$($asset.name)" -y -o"$env:LOCALAPPDATA\Soggfy\ffmpeg\" ffmpeg.exe +} +function InstallSoggfy { + Write-Host "Copying Soggfy files..." + Copy-Item -Path "$base\SpotifyOggDumper.dll" -Destination "$SpotifyDir\dpapi.dll" + Copy-Item -Path "$base\SoggfyUIC.js" -Destination "$SpotifyDir\SoggfyUIC.js" + Write-Host "Done." } function DownloadFile($Url, $DestPath) { $req = [System.Net.WebRequest]::CreateHttp($Url) $resp = $req.GetResponse() $is = $resp.GetResponseStream() + + $name = [System.IO.Path]::GetFileName($DestPath) + [System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($DestPath)) $os = [System.IO.File]::Create($DestPath) + try { $buffer = New-Object byte[] (1024 * 512) $lastProgUpdate = 0 @@ -88,33 +83,28 @@ function DownloadFile($Url, $DestPath) { $bytesRead = $is.Read($buffer, 0, $buffer.Length); if ($bytesRead -le 0) { break; } $os.Write($buffer, 0, $bytesRead); - - # Delay progress updates because they are awfully slow + + # Throttle progress updates because they slowdown download too much if ([Environment]::TickCount - $lastProgUpdate -lt 100) { continue; } $lastProgUpdate = [Environment]::TickCount; $totalReceived = $os.Position / 1048576 $totalLength = $resp.ContentLength / 1048576 Write-Progress ` - -Activity "Downloading $DestPath" ` + -Activity "Downloading $name" ` -Status ('{0:0.00}MB of {1:0.00}MB' -f $totalReceived, $totalLength) ` -PercentComplete ($totalReceived * 100 / $totalLength) } - Write-Progress -Activity "Downloading $DestPath" -Completed + Write-Progress -Activity "Downloading $name" -Completed } finally { $os.Dispose() $resp.Dispose() } } -if (-not (Test-Path '$base\Spotify\Spotify.exe') -or ((Get-Item "$base\Spotify\Spotify.exe").VersionInfo.FileVersion -ne $SpotifyVersion)) { - Write-Host "Installing Spotify..." - InstallSpotify -} -where.exe /q ffmpeg -if (-not (Test-Path '$base\ffmpeg\ffmpeg.exe') -and ($LastExitCode -eq 1)) { - Write-Host "Installing ffmpeg..." - InstallFFmpeg -} -Write-Host "Everything done. You can now run Injector.exe" +CheckOrInstallSpotify +InstallSoggfy +InstallFFmpeg + +Write-Host "Everything done. Soggfy will be enabled on the next Spotify launch."; Pause diff --git a/SpotifyOggDumper/Data/Uninstall.cmd b/SpotifyOggDumper/Data/Uninstall.cmd new file mode 100644 index 0000000..3b47cde --- /dev/null +++ b/SpotifyOggDumper/Data/Uninstall.cmd @@ -0,0 +1,5 @@ +del %appdata%\Spotify\dpapi.dll +del %appdata%\Spotify\SoggfyUIC.js +rmdir /q /s %localappdata%\Soggfy + +pause \ No newline at end of file diff --git a/SpotifyOggDumper/Main.cpp b/SpotifyOggDumper/Main.cpp index 69c6d01..4c0b3f6 100644 --- a/SpotifyOggDumper/Main.cpp +++ b/SpotifyOggDumper/Main.cpp @@ -3,37 +3,18 @@ #include "StateManager.h" #include "Utils/Log.h" #include "Utils/Hooks.h" +#include "Utils/Utils.h" #include "CefUtils.h" HMODULE _selfModule; std::shared_ptr _stateMgr; -template -constexpr char* TraversePointers(void* ptr) -{ - for (int offset : { Offsets... }) { - ptr = *(char**)((char*)ptr + offset); - } - return (char*)ptr; -} -std::string ToHex(const uint8_t* data, int length) -{ - std::string str(length * 2, '\0'); - - for (int i = 0; i < length; i++) { - const char ALPHA[] = "0123456789abcdef"; - str[i * 2 + 0] = ALPHA[(data[i] >> 4) & 15]; - str[i * 2 + 1] = ALPHA[(data[i] >> 0) & 15]; - } - return str; -} - struct PlayerState { uint8_t unknown1[0x3E0]; uint8_t playback_id[16]; std::string getPlaybackId() const { - return ToHex(playback_id, 16); + return Utils::ToHex(playback_id, 16); } }; @@ -94,7 +75,7 @@ DETOUR_FUNC(__fastcall, int, DecodeAudioData, ( int samplesDecoded = sampleBuffer.size - param_3->size; if (bytesRead > 0) { - auto playerState = (PlayerState*)TraversePointers<0, -0x40, 0x40, 0x128, 0x1E8, 0x150>(_ebp); + auto playerState = (PlayerState*)Utils::TraversePointers<0, -0x40, 0x40, 0x128, 0x1E8, 0x150>(_ebp); std::string playbackId = playerState->getPlaybackId(); _stateMgr->ReceiveAudioData(playbackId, encodedBuffer.data, bytesRead); } @@ -115,12 +96,11 @@ void InstallHooks() //Signatures for Spotify v1.2.25+ CREATE_HOOK_PATTERN(DecodeAudioData, "Spotify.exe", "55 8B EC 51 56 8B 75 0C 8D 55 0C 57 FF 75 14 8B 7D 10 8B 46 04 52 8D 55 FC 89 45 FC FF 37 8B 47 04 52 FF 36 89 45 0C 8B 01 FF 75 08 FF 50 04 8B 06 8B 4D FC 29 4E 04 8D 14 88 8B 45 08 89 16 8B 17 8B 4F 04 03 55 0C 2B 4D 0C 89 17 89 4F 04 5F 5E C9 C2 10 00"); - auto urlreqHook = CefUtils::InitUrlBlocker([&](auto url) { return _stateMgr && _stateMgr->IsUrlBlocked(url); }); - Hooks::CreateApi(L"libcef.dll", "cef_urlrequest_create", urlreqHook.first, urlreqHook.second); + CefUtils::InitUrlBlocker([&](auto url) { return _stateMgr && _stateMgr->IsUrlBlocked(url); }); Hooks::EnableAll(); } -std::filesystem::path GetModuleFileNameEx(HMODULE module) +std::filesystem::path GetModulePath(HMODULE module) { //https://stackoverflow.com/a/33613252 std::vector pathBuf; @@ -168,7 +148,12 @@ void Exit() DWORD WINAPI Init(LPVOID param) { - auto dataDir = GetModuleFileNameEx(_selfModule).parent_path(); + auto dataDir = Utils::GetLocalAppDataFolder() / "Soggfy"; + auto moduleDir = GetModulePath(_selfModule).parent_path(); + + if (!fs::exists(dataDir)) { + fs::create_directories(dataDir); + } bool logToCon = true; fs::path logFile = dataDir / "log.txt"; @@ -182,7 +167,7 @@ DWORD WINAPI Init(LPVOID param) try { InitLogger(logToCon, logFile); - _stateMgr = StateManager::New(dataDir); + _stateMgr = StateManager::New(dataDir, moduleDir); spotifyVersion = GetFileVersion(L"Spotify.exe"); LogInfo("Spotify version: {}", spotifyVersion); @@ -194,8 +179,7 @@ DWORD WINAPI Init(LPVOID param) auto msg = std::format( "Failed to initialize Soggfy: {}\n\n" "This likely means that the Spotify version you are using ({}) is not supported.\n" - "Try updating Soggfy, or downgrading Spotify to the latest supported " - "version linked in the readme.", + "Try updating Soggfy, or downgrading Spotify to the supported version.", ex.what(), spotifyVersion ); LogError("{}", msg); diff --git a/SpotifyOggDumper/StateManager.cpp b/SpotifyOggDumper/StateManager.cpp index 19f2603..0e21eda 100644 --- a/SpotifyOggDumper/StateManager.cpp +++ b/SpotifyOggDumper/StateManager.cpp @@ -33,19 +33,18 @@ struct StateManagerImpl : public StateManager json _config; - fs::path _dataDir; - fs::path _configPath; + fs::path _dataDir, _uicScriptPath; fs::path _ffmpegPath; ControlServer _ctrlSv; - StateManagerImpl(const fs::path& dataDir) : - _dataDir(dataDir), + StateManagerImpl(const fs::path& dataDir, const fs::path& moduleDir) : _ctrlSv(std::bind(&StateManagerImpl::HandleMessage, this, std::placeholders::_1, std::placeholders::_2)) { - _configPath = _dataDir / "config.json"; + _dataDir = dataDir; + _uicScriptPath = moduleDir / "SoggfyUIC.js"; - std::ifstream configFile(_configPath); + std::ifstream configFile(_dataDir / "config.json"); if (configFile.good()) { _config = json::parse(configFile, nullptr, true, true); } else { @@ -60,7 +59,7 @@ struct StateManagerImpl : public StateManager } //delete old temp files std::error_code deleteError; - fs::remove_all(dataDir / "temp", deleteError); + fs::remove_all(_dataDir / "temp", deleteError); _ffmpegPath = FindFFmpegPath(); if (_ffmpegPath.empty()) { @@ -78,19 +77,25 @@ struct StateManagerImpl : public StateManager { auto& content = msg.Content; - LogTrace("ControlMessage: {} {} + {} bytes", (int)msg.Type, msg.Content.dump(), msg.BinaryContent.size()); + if (msg.Type != MessageType::IDLE) { + LogTrace("ControlMessage: {} {} + {} bytes", (int)msg.Type, msg.Content.dump(), msg.BinaryContent.size()); + } switch (msg.Type) { - case MessageType::SERVER_OPEN: { - std::ifstream srcJsFile(_dataDir / "Soggfy.js", std::ios::binary); + case MessageType::IDLE: { + std::ifstream srcJsFile(_uicScriptPath, std::ios::binary); if (srcJsFile.good()) { - LogInfo("Injecting JS..."); + LogInfo("Attempting to inject client JS bundle..."); std::string srcJs(std::istreambuf_iterator(srcJsFile), {}); - Utils::Replace(srcJs, "ws://127.0.0.1:28653/sgf_ctrl", msg.Content["addr"]); + + srcJs.insert(0, "if (!window.__sgf_nonce) { window.__sgf_nonce=1;\n\n"); + srcJs.append("\n}"); + + std::string addr = "ws://127.0.0.1:" + std::to_string(_ctrlSv.GetListenPort()) + "/sgf_ctrl"; + Utils::Replace(srcJs, "ws://127.0.0.1:28653/sgf_ctrl", addr); CefUtils::InjectJS(srcJs); } - LogInfo("Ready"); break; } case MessageType::HELLO: { @@ -104,7 +109,7 @@ struct StateManagerImpl : public StateManager for (auto& [key, val] : content.items()) { _config[key] = val; } - std::ofstream(_configPath) << _config.dump(4); + std::ofstream(_dataDir / "config.json") << _config.dump(4); if (!_config.value("downloaderEnabled", true)) { std::lock_guard lock(_mutex); @@ -569,6 +574,12 @@ struct StateManagerImpl : public StateManager return outPath; } + fs::path MakeTempPath(const std::string& filename) + { + fs::create_directories(_dataDir / "temp"); + return _dataDir / "temp" / filename; + } + fs::path FindFFmpegPath() { //Try Soggfy/ffmpeg/ffmpeg.exe @@ -588,15 +599,9 @@ struct StateManagerImpl : public StateManager } return {}; } - - fs::path MakeTempPath(const std::string& filename) - { - fs::create_directories(_dataDir / "temp"); - return _dataDir / "temp" / filename; - } }; -std::unique_ptr StateManager::New(const fs::path& dataDir) +std::unique_ptr StateManager::New(const fs::path& dataDir, const fs::path& moduleDir) { - return std::make_unique(dataDir); + return std::make_unique(dataDir, moduleDir); } \ No newline at end of file diff --git a/SpotifyOggDumper/StateManager.h b/SpotifyOggDumper/StateManager.h index 62f2187..33563ba 100644 --- a/SpotifyOggDumper/StateManager.h +++ b/SpotifyOggDumper/StateManager.h @@ -14,5 +14,5 @@ class StateManager //Closes any open files and the control server. virtual void Shutdown() = 0; - static std::unique_ptr New(const std::filesystem::path& dataDir); + static std::unique_ptr New(const fs::path& dataDir, const fs::path& moduleDir); }; \ No newline at end of file diff --git a/SpotifyOggDumper/Utils/Utils.cpp b/SpotifyOggDumper/Utils/Utils.cpp index a7e4444..054cb68 100644 --- a/SpotifyOggDumper/Utils/Utils.cpp +++ b/SpotifyOggDumper/Utils/Utils.cpp @@ -172,6 +172,10 @@ namespace Utils { return GetKnownPath(FOLDERID_RoamingAppData); } + fs::path GetLocalAppDataFolder() + { + return GetKnownPath(FOLDERID_LocalAppData); + } fs::path GetMusicFolder() { return GetKnownPath(FOLDERID_Music); diff --git a/SpotifyOggDumper/Utils/Utils.h b/SpotifyOggDumper/Utils/Utils.h index 26345f7..732049c 100644 --- a/SpotifyOggDumper/Utils/Utils.h +++ b/SpotifyOggDumper/Utils/Utils.h @@ -36,6 +36,7 @@ namespace Utils std::string ExpandEnvVars(const std::string& str); fs::path GetAppDataFolder(); + fs::path GetLocalAppDataFolder(); fs::path GetMusicFolder(); void RevealInFileExplorer(const fs::path& path); @@ -51,7 +52,25 @@ namespace Utils */ fs::path NormalizeToLongPath(const fs::path& path, bool force = false); - int64_t CurrentMillis(); + template + constexpr char* TraversePointers(void* ptr) + { + for (int offset : { Offsets... }) { + ptr = *(char**)((char*)ptr + offset); + } + return (char*)ptr; + } + inline std::string ToHex(const uint8_t* data, int length) + { + std::string str(length * 2, '\0'); + + for (int i = 0; i < length; i++) { + const char ALPHA[] = "0123456789abcdef"; + str[i * 2 + 0] = ALPHA[(data[i] >> 4) & 15]; + str[i * 2 + 1] = ALPHA[(data[i] >> 0) & 15]; + } + return str; + } } class ProcessBuilder diff --git a/Sprinkles/dev_build.bat b/Sprinkles/dev_build.bat index ee1dd0d..daaeb0a 100644 --- a/Sprinkles/dev_build.bat +++ b/Sprinkles/dev_build.bat @@ -1,2 +1 @@ -rem npm run build & copy dist\bundle.js %appdata%\Spotify\Apps\xpui\extensions\Soggfy.js /y -npm run build & copy dist\bundle.js ..\build\Debug\Soggfy.js /y & cmd /c "cd ..\build\Debug & Injector.exe -d" \ No newline at end of file +npm run build & copy dist\SoggfyUIC.js ..\build\Debug\SoggfyUIC.js /y & cmd /c "cd ..\build\Debug & Injector.exe -a" \ No newline at end of file diff --git a/Sprinkles/src/player-state-tracker.ts b/Sprinkles/src/player-state-tracker.ts index 74dde83..3ae05ac 100644 --- a/Sprinkles/src/player-state-tracker.ts +++ b/Sprinkles/src/player-state-tracker.ts @@ -14,17 +14,17 @@ export default class PlayerStateTracker { let lastPlaybackId = null as string; Player.getEvents().addListener("update", ({ data }) => { - if (!data.playbackId) return; - - if (!this.playbacks.has(data.playbackId)) { - conn.send(MessageType.DOWNLOAD_STATUS, { playbackId: data.playbackId, ignore: isTrackIgnored(data.item) }); - conn.send(MessageType.PLAYER_STATE, { event: "trackstart", playbackId: data.playbackId }); - } if (data.playbackId !== lastPlaybackId && lastPlaybackId != null) { conn.send(MessageType.PLAYER_STATE, { event: "trackend", playbackId: lastPlaybackId }); } lastPlaybackId = data.playbackId; + if (!data.playbackId) return; + + if (!this.playbacks.has(data.playbackId)) { + conn.send(MessageType.DOWNLOAD_STATUS, { playbackId: data.playbackId, ignore: isTrackIgnored(data.item) }); + conn.send(MessageType.PLAYER_STATE, { event: "trackstart", playbackId: data.playbackId }); + } this.playbacks.set(data.playbackId, data); }); @@ -272,6 +272,8 @@ export default class PlayerStateTracker { } } let tracksToRemove = queue.nextUp.filter(v => isTrackIgnored(v) || statusCache.get(v.uri) === true); - await Player.removeFromQueue(tracksToRemove); + if (tracksToRemove.length > 0) { + await Player.removeFromQueue(tracksToRemove); + } } } \ No newline at end of file diff --git a/Sprinkles/src/ui/status-indicator.ts b/Sprinkles/src/ui/status-indicator.ts index f209226..aacd2a2 100644 --- a/Sprinkles/src/ui/status-indicator.ts +++ b/Sprinkles/src/ui/status-indicator.ts @@ -3,6 +3,7 @@ import { Connection, MessageType } from "../connection"; import { Icons, Selectors } from "./ui-assets"; import Utils from "../utils"; import { TemplatedSearchTree } from "../path-template"; +import { Player } from "../spotify-apis"; export const enum DownloadStatus { Error = "ERROR", @@ -99,14 +100,24 @@ ${StatusIcons[info.status]}`; private async sendUpdateRequest(dirtyRows: HTMLDivElement[]) { let tree = new TemplatedSearchTree(config.savePaths.track); + let currentPlayingTrackUri = Player.getState().item?.uri; + let requestStatusForCurrentPlayback = false; for (let trackInfo of this.getTrackInfoFromRows(dirtyRows)) { tree.add(trackInfo.uri, trackInfo.vars); + + if (trackInfo.uri === currentPlayingTrackUri) { + requestStatusForCurrentPlayback = true; + } } this.conn.send(MessageType.DOWNLOAD_STATUS, { searchTree: tree.root, basePath: config.savePaths.basePath }); + + if (requestStatusForCurrentPlayback) { + this.conn.send(MessageType.DOWNLOAD_STATUS, { playbackId: Player.getState().playbackId }); + } } private * getTrackInfoFromRows(rows?: Iterable) { diff --git a/Sprinkles/src/ui/ui.ts b/Sprinkles/src/ui/ui.ts index 2fe138f..213b527 100644 --- a/Sprinkles/src/ui/ui.ts +++ b/Sprinkles/src/ui/ui.ts @@ -104,14 +104,16 @@ export default class UI { let onChange = this.updateConfig.bind(this); let defaultFormats = { - "Original OGG": { ext: "", args: "-c copy" }, - "MP3 320K": { ext: "mp3", args: "-c:a libmp3lame -b:a 320k -id3v2_version 3 -c:v copy" }, - "MP3 256K": { ext: "mp3", args: "-c:a libmp3lame -b:a 256k -id3v2_version 3 -c:v copy" }, - "MP3 192K": { ext: "mp3", args: "-c:a libmp3lame -b:a 192k -id3v2_version 3 -c:v copy" }, - "M4A 256K (AAC)": { ext: "m4a", args: "-c:a aac -b:a 256k -disposition:v attached_pic -c:v copy" }, //TODO: aac quality disclaimer / libfdk - "M4A 192K (AAC)": { ext: "m4a", args: "-c:a aac -b:a 192k -disposition:v attached_pic -c:v copy" }, - "Opus 160K": { ext: "opus",args: "-c:a libopus -b:a 160k" }, - "Custom": { ext: "mp3", args: "-c:a libmp3lame -b:a 320k -id3v2_version 3 -c:v copy" }, + "Original OGG": { ext: "", args: "-c copy" }, + "MP3 320K": { ext: "mp3", args: "-c:a libmp3lame -b:a 320k -id3v2_version 3 -c:v copy" }, + "MP3 256K": { ext: "mp3", args: "-c:a libmp3lame -b:a 256k -id3v2_version 3 -c:v copy" }, + "MP3 192K": { ext: "mp3", args: "-c:a libmp3lame -b:a 192k -id3v2_version 3 -c:v copy" }, + // https://trac.ffmpeg.org/wiki/Encode/AAC + "M4A 256K (FDK AAC)": { ext: "m4a", args: "-c:a libfdk_aac -b:a 256k -cutoff 20k -disposition:v attached_pic -c:v copy" }, + "M4A 224K VBR (FDK AAC)":{ext: "m4a", args: "-c:a libfdk_aac -vbr 5 -disposition:v attached_pic -c:v copy" }, + "M4A 160K (FDK AAC)": { ext: "m4a", args: "-c:a libfdk_aac -b:a 160k -cutoff 18k -disposition:v attached_pic -c:v copy" }, + "Opus 160K": { ext: "opus",args: "-c:a libopus -b:a 160k" }, + "Custom": { ext: "mp3", args: "-c:a libmp3lame -b:a 320k -id3v2_version 3 -c:v copy" }, }; let extensions = { "MP3": "mp3", "M4A": "m4a", "MP4": "mp4", @@ -202,7 +204,7 @@ export default class UI { } public showNotification(icon: string, text: string) { - let anchor = document.querySelector("[data-testid='now-playing-bar']"); + let anchor = document.querySelector(".Root__now-playing-bar, [data-testid='now-playing-bar']"); let node = UIC.parse(`
diff --git a/Sprinkles/webpack.config.js b/Sprinkles/webpack.config.js index 8ceae29..9f53e44 100644 --- a/Sprinkles/webpack.config.js +++ b/Sprinkles/webpack.config.js @@ -45,6 +45,6 @@ module.exports = { }, output: { path: path.resolve(__dirname, "dist"), - filename: "bundle.js", + filename: "SoggfyUIC.js", } }; \ No newline at end of file diff --git a/USAGE.md b/USAGE.md deleted file mode 100644 index 4373b8b..0000000 --- a/USAGE.md +++ /dev/null @@ -1,30 +0,0 @@ -# Usage -Notes and comments on some features, installation and usage can be found in [README](/README.md). - -## Playback Speed -This feature allows faster downloads by forcing the player to decode audio quicker, it does not generate files of lower quality. The slider is limited to 20x by default, but any value can be set in the textbox on the left side. -Notes (may be outdated): -- Speed >= ~30x will cause the player to stutter and silently spam requests to a Spotify API, which could increase the chances of your account getting banned or rate limited. -- Speed >= ~200x will saturate CPU (use 100% of a core or more). This could increase the chance of crashes or unexpected behavior. -Stuttering may happen just after changing the value, but it generally stops on the next song. There also seems to be a memory leak related to it, but it goes away after some time. (?) - -## High quality AAC -FFmpeg's native AAC encoder is known to generate poor quality files[^1][^2], and mainstream builds don't include the higher quality encoder _libfdk_aac_ due to patent licensing issues. Because PowerShell doesn't support extracting 7z, this action must be performed manually by the user. - -1. Build _or_ download ffmpeg with libfdk: - - https://github.com/AnimMouse/ffmpeg-autobuild/releases - - https://github.com/marierose147/ffmpeg_windows_exe_with_fdk_aac/releases - -2. Extract `ffmpeg.exe` into a newly created `ffmpeg` folder on the `Soggfy` directory (such that the final directory structure looks like `Soggfy/ffmpeg/ffmpeg.exe`). - -3. Launch Soggfy, select `Custom` in the output format option, and paste these values: - - Arguments: `-c:a libfdk_aac -b:a 256k -disposition:v attached_pic -c:v copy` - - Extension: either `m4a` or `mp4`. - -_You may change `-b:a 256k` to your preferred bitrate, and/or use different parameters[^2]._ - -Note that some iPods may have issues playing AAC files generated by ffmpeg (https://trac.ffmpeg.org/ticket/7747). - -# Links -[^1]: https://trac.ffmpeg.org/wiki/Encode/HighQualityAudio -[^2]: https://trac.ffmpeg.org/wiki/Encode/AAC \ No newline at end of file diff --git a/fetch_external_deps.bat b/fetch_external_deps.bat index 865136f..f9cfc10 100644 --- a/fetch_external_deps.bat +++ b/fetch_external_deps.bat @@ -1,5 +1,5 @@ rmdir /s /q external mkdir external -curl https://cef-builds.spotifycdn.com/cef_binary_102.0.10+gf249b2e+chromium-102.0.5005.115_windows32_minimal.tar.bz2 --output external/cef_bin.tar.bz2 +curl https://cef-builds.spotifycdn.com/cef_binary_119.4.4+g5d1e039+chromium-119.0.6045.199_windows32_minimal.tar.bz2 --output external/cef_bin.tar.bz2 7z x "external/cef_bin.tar.bz2" -so | 7z x -aoa -si -ttar "-xr!*.dll" "-xr!cef_sandbox.lib" -o"external/" move /y "external\cef_binary_*" "external\cef_bin" \ No newline at end of file