heic_sync/Download-ODLivePhotosV2.ps1

348 lines
15 KiB
PowerShell

<#
.DESCRIPTION
This script attempts to authenticate to OneDrive as client https://photos.onedrive.com
(which has permissions to download LivePhotos) and downloads all LivePhotos at a given
location within your personal OneDrive.
.PARAMETER SaveTo
Target path where to save live photos.
.PARAMETER AccessToken
OneDrive application access token.
.PARAMETER PathToScan
DOS-Style path on your OneDrive that should be scanned. Most likely '\Pictures\Camera Roll' or any other shared Camera Roll folder.
.EXAMPLE
.\Download-ODLivePhotos2.ps1 'C:\Live Photos'
.NOTES
Author: Petr Vyskocil
There is no error checking, so it is recommended to re-run the command on bigger libraries.
If there are any errors during the download (OneDrive sometimes fails randomly with error "Our
services aren't available right now. We're working to restore all services as soon as possible.
Please check back soon."), next run will download the missing files and skip already downloaded
ones.
#>
param (
[Parameter(Mandatory)]
[string] $SaveTo,
[string] $AccessToken,
[string] $PathToScan = '\Pictures\Camera Roll'
)
function Register-WebView2Type
{
<#
.DESCRIPTION
Check that WebView2 exists, if not obtain it from nuget.
.EXAMPLE
Register-WebView2Type
.NOTES
Author: Petr Vyskocil
#>
$Package = "Microsoft.Web.WebView2"
$Version = "1.0.3124.44"
$BasePath = "$env:temp\ODLivePhotos\"
Write-Host "Checking for WebView2 assemblies..."
if (!("Microsoft.Web.WebView2.WinForms.WebView2" -as [type])) {
Write-Host "WebView2 type not found, attempting to load from local cache..."
if (!(Test-Path "$($BasePath)\Microsoft.Web.WebView2.WinForms.dll")) {
Write-Host " Downloading nuget package $($Package) $($Version)"
Install-Package -Source "https://www.nuget.org/api/v2" -Name $Package -RequiredVersion $Version -Scope CurrentUser -Destination $BasePath -Force
Write-Host " Copying package files to script directory"
foreach ( $Framework in (Get-ChildItem "$BasePath\$($Package).$($Version)\lib" -Directory) ) {
Write-Host " Processing framework: $Framework"
Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\lib\$($Framework)\*.dll" -Destination $BasePath -Force
}
$Arch = (Get-CimInstance Win32_operatingsystem).OSArchitecture
Write-Host " Detected architecture: $Arch"
if ($Arch -eq "32-bit") {
Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\runtimes\win-x86\native\*.dll" -Destination $BasePath -Force
}
if ($Arch -eq "64-bit") {
Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\runtimes\win-x64\native\*.dll" -Destination $BasePath -Force
}
if ($Arch -eq "ARM 64-bit Processor") {
Copy-Item -Recurse -Path "$BasePath\$($Package).$($Version)\runtimes\win-arm64\native\*.dll" -Destination $BasePath -Force
}
Remove-Item -Recurse -Force "$BasePath\$($Package).$($Version)"
}
Write-Host " Registering WebView2 type from: $BasePath\Microsoft.Web.WebView2.WinForms.dll"
Add-Type -Path "$BasePath\Microsoft.Web.WebView2.WinForms.dll"
}
Write-Host "WebView2 type registration complete"
}
function Get-ODPhotosToken
{
Write-Host "Initializing WebView2 component..."
$Hash = [hashtable]::Synchronized(@{})
$Env:WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS = "--enable-features=msSingleSignOnOSForPrimaryAccountIsShared"
Write-Host "Creating WebView2 control..."
$Web = New-Object Microsoft.Web.WebView2.WinForms.WebView2
$Web.CreationProperties = New-Object Microsoft.Web.WebView2.WinForms.CoreWebView2CreationProperties
$Web.CreationProperties.UserDataFolder = "$env:temp\ODLivePhotos\"
Write-Host "Setting up WebView2 event handlers..."
$Web.add_NavigationStarting({
param($sender, $e)
Write-Host "Navigation starting to: $($e.Uri)"
})
$Web.add_NavigationCompleted({
param($sender, $e)
Write-Host "Navigation completed to: $($e.Uri)"
if ($e.IsSuccess) {
Write-Host "Navigation successful"
} else {
Write-Host "Navigation failed with WebErrorStatus: $($e.WebErrorStatus)"
}
})
$Web.add_CoreWebView2InitializationCompleted({
param($sender, $e)
if ($e.IsSuccess) {
Write-Host "CoreWebView2 initialized successfully"
Write-Host "Setting up user agent and response handlers..."
$Web.CoreWebView2.Settings.UserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36 Edg/134.0.3124.85'
$Web.CoreWebView2.add_WebResourceResponseReceived({
param($WebView2, $E)
if ($E.Request.Uri.StartsWith('https://my.microsoftpersonalcontent.com/_api/') -or $E.Request.Uri.StartsWith('https://api.onedrive.com/')) {
Write-Host "Detected API request: $($E.Request.Uri)"
if ($E.Request.Headers.Contains('Authorization')) {
Write-Host "Found authorization token"
$Hash.AuthToken = $E.Request.Headers.GetHeader('Authorization')
$Form.Close()
}
}
})
} else {
Write-Host "CoreWebView2 initialization failed"
Write-Host "Please ensure Microsoft Edge WebView2 Runtime is installed"
Write-Host "Download from: https://developer.microsoft.com/en-us/microsoft-edge/webview2/"
}
})
Write-Host "Creating form..."
$Form = New-Object System.Windows.Forms.Form -Property @{
Width=800
Height=800
Text="OneDrive Live Photo Downloader - Authentication Token capture dialog"
} -ErrorAction Stop
Write-Host "Setting up form..."
$Web.Dock = "Fill"
$Form.Controls.Add($web)
Write-Host "Setting initial URL..."
$Web.source = "https://onedrive.live.com/?qt=allmyphotos&photosData=%2F&sw=bypassConfig&v=photos"
Write-Host "Showing authentication dialog..."
$Form.Add_Shown( { $form.Activate() } )
$Form.ShowDialog() | Out-Null
Write-Host "Dialog closed, cleaning up..."
$Web.Dispose()
if ($Hash.AuthToken) {
Write-Host "Authentication token obtained successfully"
} else {
Write-Host "No authentication token was obtained"
}
return $Hash.AuthToken
}
function Download-LivePhotosAuth
{
<#
.DESCRIPTION
Download all Live Photos from a given OneDrive path
.PARAMETER AccessToken
Access token for OneDrive API that has ability to download live photos.
.PARAMETER SaveTo
Target path where to save live photos.
.PARAMETER PathToScan
DOS-Style path on your OneDrive that should be scanned. Most likely '\Pictures\Camera Roll' or any other shared Camera Roll filder you see.
.PARAMETER CurrentPath
Internal, used for recursion
.PARAMETER ElementId
Start at folder id (probably only useful internally for recursion too)
.PARAMETER Uri
Start processing on this URI (again for recursion)
.EXAMPLE
Download-LivePhotosAuth -AccessToken $token -SaveTo 'C:\LivePhotos' -PathToScan '\Pictures\Camera Roll'
.NOTES
Author: Petr Vyskocil
#>
PARAM(
[Parameter(Mandatory=$True)]
[string]$AccessToken,
[Parameter(Mandatory=$True)]
[string]$SaveTo,
[string]$PathToScan='\',
[string]$CurrentPath='',
[string]$ElementId='',
[string]$DriveId='',
[string]$Uri=''
)
if (!$PathToScan.EndsWith('\')) { $PathToScan = $PathToScan + '\' }
if (!$PathToScan.StartsWith('\')) { $PathToScan = '\' + $PathToScan }
if ($Uri -eq '') {
if ($ElementId -eq '') {
$CurrentPath='\'
$Location='root'
} else {
$Location='items/' + $ElementId
}
if ($DriveId -eq '') {
$Uri = 'https://my.microsoftpersonalcontent.com/_api/v2.1/drive/' + $Location + '/children?%24filter=photo%2FlivePhoto+ne+null+or+folder+ne+null+or+remoteItem+ne+null&select=fileSystemInfo%2Cphoto%2Cid%2Cname%2Csize%2Cfolder%2CremoteItem'
} else {
$Uri = 'https://my.microsoftpersonalcontent.com/_api/v2.1/drives/' +$DriveId + '/' + $Location + '/children?%24filter=photo%2FlivePhoto+ne+null+or+folder+ne+null+or+remoteItem+ne+null&select=fileSystemInfo%2Cphoto%2Cid%2Cname%2Csize%2Cfolder%2CremoteItem'
}
}
Write-Debug("Calling OneDrive API")
Write-Debug($Uri)
$WebRequest=Invoke-WebRequest -Method 'GET' -Header @{ Authorization = $AccessToken; Prefer = "Include-Feature=AddToOneDrive"} -ErrorAction SilentlyContinue -Uri $Uri
$Response = ConvertFrom-Json $WebRequest.Content
$Response.value | % {
$FolderPath = $CurrentPath + $_.name + '\'
if ([bool]$_.PSObject.Properties['folder']) {
if ($FolderPath.StartsWith($PathToScan) -or $PathToScan.StartsWith($FolderPath)) { # We're traversing the target folder or we're getting into it
Write-Output("Checking folder $($_.id) - $($FolderPath)")
Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $FolderPath -ElementId $_.id -DriveId $DriveId
}
}
if ([bool]$_.PSObject.Properties['remoteItem']) {
if ($FolderPath.StartsWith($PathToScan) -or $PathToScan.StartsWith($FolderPath)) { # We're traversing the target folder or we're getting into it
Write-Output("Checking shared folder $($_.remoteItem.id) - $($FolderPath)")
Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $FolderPath -ElementId $_.remoteItem.id -DriveId $_.remoteItem.parentReference.driveId
}
}
if ([bool]$_.PSObject.Properties['photo']) {
if ([bool]$_.photo.PSObject.Properties['livePhoto']) {
if ($CurrentPath.StartsWith($PathToScan)) {
$TargetPath = $SaveTo + '\' + $CurrentPath.Substring($PathToScan.Length)
$VideoName = ([io.fileinfo]$_.name).basename+'.mov'
$VideoLen = $_.photo.livePhoto.totalStreamSize - $_.size
if ( (Test-Path($TargetPath+$_.name)) -and # Target image exists
(Test-Path($TargetPath+$VideoName)) -and # Target video exists
((Get-Item($TargetPath+$_.name)).Length -eq $_.size) -and # size of image is onedrive's size
((Get-Item($TargetPath+$VideoName)).Length -eq $VideoLen) # size of video is onedrive's size
) {
Write-Output "Live photo $($_.id) - $($CurrentPath + $_.name) already exists at $($TargetPath) - skipping."
} else {
Write-Output("Detected live photo $($_.id) - $($CurrentPath + $_.name). Saving image/video pair to $($TargetPath)")
Download-SingleLivePhoto -AccessToken $AccessToken -ElementId $_.id -DriveId $DriveId -SaveTo $TargetPath -ImageName $_.name -VideoName $VideoName -ExpImgLen $_.size -ExpVidLen $VideoLen -LastModified $_.fileSystemInfo.lastModifiedDateTime
}
}
}
}
}
if ([bool]$Response.PSobject.Properties["@odata.nextLink"])
{
write-debug("Getting more elements form service (@odata.nextLink is present)")
Download-LivePhotosAuth -AccessToken $AccessToken -SaveTo $SaveTo -PathToScan $PathToScan -CurrentPath $CurrentPath -Uri $Response.'@odata.nextLink'
}
}
function Download-SingleLivePhoto
{
<#
.DESCRIPTION
Download single LivePhoto given it's ElementId and static data.
.PARAMETER AccessToken
Access token for OneDrive API that has ability to download live photos.
.PARAMETER ElementId
OneDrive ElementId of a LivePhoto
.PARAMETER DriveId
OneDrive DriveId of a LivePhoto
.PARAMETER SaveTo
Target path where to save live photos.
.PARAMETER ExpImgLen
Expected length of image file, as reported in containing folder.
.PARAMETER ExpVidLen
Expected length of video file, as reported in containing folder.
.PARAMETER LastModified
Date to set on a created file.
.NOTES
Author: Petr Vyskocil
#>
PARAM(
[Parameter(Mandatory=$True)]
[string]$AccessToken,
[Parameter(Mandatory=$True)]
[string]$ElementId,
[string]$DriveId,
[Parameter(Mandatory=$True)]
[string]$SaveTo,
[Parameter(Mandatory=$True)]
[string]$ImageName,
[Parameter(Mandatory=$True)]
[string]$VideoName,
[Parameter(Mandatory=$True)]
[int]$ExpImgLen,
[Parameter(Mandatory=$True)]
[int]$ExpVidLen,
[Parameter(Mandatory=$True)]
[datetime]$LastModified
)
if (!(Test-Path $SaveTo)) { New-Item -ItemType Directory -Force $SaveTo | Out-Null }
# video part
if ($DriveId -eq '') {
$Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drive/items/$($ElementId)/content?format=video"
} else {
$Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drives/$($DriveId)/items/$($ElementId)/content?format=video"
}
Write-Debug("Calling OneDrive API")
Write-Debug($Uri)
$FileName = $SaveTo+$VideoName
If (Test-Path($FileName)) { Remove-Item ($FileName) }
$WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = $AccessToken } -ErrorAction SilentlyContinue -OutFile $FileName -PassThru
$ActualLen = $WebRequest.RawContentLength
(Get-Item ($FileName)).LastWriteTime = $LastModified
If ($ActualLen -ne $ExpVidLen) { Write-Error("Error saving video part of live photo $ElementId. Got $ActualLen bytes, expected $ExpVidLen bytes.") }
# image part
if ($DriveId -eq '') {
$Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drive/items/$($ElementId)/content"
} else {
$Uri = "https://my.microsoftpersonalcontent.com/_api/v2.1/drives/$($DriveId)/items/$($ElementId)/content"
}
Write-Debug("Calling OneDrive API")
Write-Debug($Uri)
$FileName = $SaveTo+$ImageName
If (Test-Path($FileName)) { Remove-Item ($FileName) }
$WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = $AccessToken } -ErrorAction SilentlyContinue -OutFile $FileName -PassThru
$ActualLen = $WebRequest.RawContentLength
(Get-Item ($FileName)).LastWriteTime = $LastModified
if ($ActualLen -ne $ExpImgLen) { Write-Error("Error saving photo part of live photo $ElementId. Got $ActualLen bytes, expected $ExpImgLen bytes.") }
}
Write-Output "Live Photo downloader - Downloads Live Photos from OneDrive camera roll as saved by OneDrive iOS app."
Write-Output "(C) 2024-2025 Petr Vyskocil. Licensed under MIT license."
Write-Output ""
# This disables powershell progress indicators, speeding up Invoke-WebRequest with big results by a factor of 10 or so
$ProgressPreference = 'SilentlyContinue'
if ($AccessToken -eq '') {
Write-Output "Creating WebView2 component..."
Register-WebView2Type
Write-Output "Getting OneDrive Authentication token..."
$AccessToken = Get-ODPhotosToken
}
Write-Output "Downloading Live Photos..."
Download-LivePhotosAuth -AccessToken $AccessToken -PathToScan $PathToScan -SaveTo $SaveTo