348 lines
15 KiB
PowerShell
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 |