heic_sync/Download-ODLivePhotos.ps1

281 lines
12 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 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-ODLivePhotos.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] $PathToScan = '/Photos/Auto-saved'
)
function Get-ODPhotosToken
{
<#
.DESCRIPTION
Connect to OneDrive for authentication with a OneDrive web Photos client id. Adapted from https://github.com/MarcelMeurer/PowerShellGallery-OneDrive to mimic OneDrive Photos web OIDC login.
Unfortunately using custom ClientId seems impossible - generic OD client IDs are missing the ability to download Live Photos.
.PARAMETER ClientId
ClientId of OneDrive Photos web app (073204aa-c1e0-4e66-a200-e5815a0aa93d)
.PARAMETER Scope
Comma-separated string defining the authentication scope (https://dev.onedrive.com/auth/msa_oauth.htm). Default: "OneDrive.ReadWrite,offline_access,openid,profile".
.PARAMETER RedirectURI
Code authentication requires a correct URI. Must be https://photos.onedrive.com/auth/login.
.EXAMPLE
$access_token=Get-ODPhotosToken
Connect to OneDrive for authentication and save the token to $access_token
.NOTES
Author: Petr Vyskocil
#>
PARAM(
[string]$ClientId = "073204aa-c1e0-4e66-a200-e5815a0aa93d",
[string]$Scope = "OneDrive.ReadWrite,offline_access,openid,profile",
[string]$RedirectURI ="https://photos.onedrive.com/auth/login",
[switch]$DontShowLoginScreen=$false,
[switch]$LogOut
)
$Authentication=""
[Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") | out-null
[Reflection.Assembly]::LoadWithPartialName("System.Drawing") | out-null
[Reflection.Assembly]::LoadWithPartialName("System.Web") | out-null
if ($Logout)
{
$URIGetAccessToken="https://login.live.com/logout.srf"
}
else
{
$URIGetAccessToken="https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize?client_id="+$ClientId+"&nonce=uv."+(New-Guid).Guid+"&response_mode=form_post&scope="+$Scope+"&response_type=code&redirect_URI="+$RedirectURI
}
$form = New-Object Windows.Forms.Form
if ($DontShowLoginScreen)
{
write-debug("Logon screen suppressed by flag -DontShowLoginScreen")
$form.Opacity = 0.0;
}
$form.text = "Authenticate to OneDrive"
$form.size = New-Object Drawing.size @(700,600)
$form.Width = 660
$form.Height = 775
$web=New-object System.Windows.Forms.WebBrowser
$web.IsWebBrowserContextMenuEnabled = $true
$web.Width = 600
$web.Height = 700
$web.Location = "25, 25"
$web.ScriptErrorsSuppressed = $true
$DocComplete = {
if ($web.Url.AbsoluteUri -match "access_token=|error|code=|logout|/auth/login") {$form.Close() } #
}
$web.Add_DocumentCompleted($DocComplete)
$form.Controls.Add($web)
$web.navigate($URIGetAccessToken)
$form.showdialog() | out-null
$Authentication = New-Object PSObject
# The returned code=XXXX is irrelevant, the actual secrets are sent as cookies:
$web.Document.Cookie -split ';' | % {
$cookie = $_ -split '='
$cookieValue = [uri]::UnescapeDataString($cookie[1])
$Authentication | Add-Member NoteProperty $cookie[0].Trim() $cookieValue
}
if (-Not $Authentication.'AccessToken-OneDrive.ReadWrite') {
write-error("Cannot get authentication token. This program does not suport token refresh at the moment. Try again, if you fail a few times, restart and try again.")
# TODO: Refresh token should be handled here : GET https://photos.onedrive.com/auth/refresh?scope=OneDrive.ReadWrite&refresh_token=
# Unfortunately it seems that this page hangs up the WebBrowser control, and we need all the cookies transferred to use...
# Deemed not worth debugging for utility that runs for a short period of time
return $web
}
return $Authentication
}
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]$Uri=''
)
if (!$PathToScan.EndsWith('\')) { $PathToScan = $PathToScan + '\' }
if (!$PathToScan.StartsWith('\')) { $PathToScan = '\' + $PathToScan }
if ($Uri -eq '') {
if ($ElementId -eq '') {
$CurrentPath='\'
$Location='root'
} else {
$Location='items/' + $ElementId
}
$Uri = 'https://api.onedrive.com/v1.0/drive/' + $Location + '/children?%24filter=photo%2FlivePhoto+ne+null+or+folder+ne+null+or+remoteItem+ne+null&select=fileSystemInfo%2Cphoto%2Cid%2Cname%2Csize%2Cfolder%2CremoteItem'
}
Write-Output("Calling OneDrive API")
Write-Output($Uri)
$WebRequest=Invoke-WebRequest -Method 'GET' -Header @{ Authorization = "BEARER "+$AccessToken} -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
}
}
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
}
}
if ([bool]$_.PSObject.Properties['photo']) {
if ([bool]$_.photo.PSObject.Properties['livePhoto']) {
if ($CurrentPath.StartsWith($PathToScan)) {
$TargetPath = $SaveTo + '\' + $CurrentPath.Substring($PathToScan.Length)
if ( (Test-Path($TargetPath+$_.name)) -and # Target image exists
(Test-Path($TargetPath+([io.fileinfo]$_.name).basename+'.mov')) -and # Target video exists
(((Get-Item($TargetPath+$_.name)).Length + (Get-Item($TargetPath+([io.fileinfo]$_.name).basename+'.mov')).Length) -eq $_.size) # size of image and video together 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 -SaveTo $TargetPath -ExpectedSize $_.size -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 SaveTo
Target path where to save live photos.
.PARAMETER ExpectedSize
Sum of photo and video file sizes, as reported in the 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,
[Parameter(Mandatory=$True)]
[string]$SaveTo,
[Parameter(Mandatory=$True)]
[int]$ExpectedSize,
[Parameter(Mandatory=$True)]
[datetime]$LastModified
)
if (!(Test-Path $SaveTo)) { New-Item -ItemType Directory -Force $SaveTo | Out-Null }
# video part
$Uri = "https://api.onedrive.com/v1.0/drive/items/$($ElementId)/content?format=video"
Write-Output("Calling OneDrive API")
Write-Output($Uri)
$TmpFile = $SaveTo+'tmp-file.mov'
$WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = "BEARER "+$AccessToken } -ErrorAction SilentlyContinue -OutFile $TmpFile -PassThru
$ActualSize = $WebRequest.RawContentLength
$FileName = ($WebRequest.Headers.'Content-Disposition'.Split('=',2)[-1]).Trim('"')
if ($FileName) {
Write-Debug("Renaming $TmpFile to $FileName")
if (Test-Path($SaveTo+$FileName)) { Remove-Item ($SaveTo+$FileName) }
Rename-Item -Path $TmpFile -NewName $FileName
(Get-Item ($SaveTo+$FileName)).LastWriteTime = $LastModified
}
# image part
$Uri = "https://api.onedrive.com/v1.0/drive/items/$($ElementId)/content"
Write-Output("Calling OneDrive API")
Write-Output($Uri)
$TmpFile = $SaveTo+'tmp-file.img'
$WebRequest=Invoke-WebRequest -Method "GET" -Uri $Uri -Header @{ Authorization = "BEARER "+$AccessToken } -ErrorAction SilentlyContinue -OutFile $TmpFile -PassThru
$ActualSize = $ActualSize + $WebRequest.RawContentLength
$FileName = ($WebRequest.Headers.'Content-Disposition'.Split('=',2)[-1]).Trim('"')
if ($FileName) {
Write-Debug("Renaming $TmpFile to $FileName")
if (Test-Path($SaveTo+$FileName)) { Remove-Item ($SaveTo+$FileName) }
Rename-Item -Path $TmpFile -NewName $FileName
(Get-Item ($SaveTo+$FileName)).LastWriteTime = $LastModified
}
if ($ActualSize -ne $ExpectedSize) { Write-Error("Error saving live photo $ElementId. Got $ActualSize bytes, expected $ExpectedSize bytes.") }
}
Write-Output "Live Photo downloader - Downloads Live Photos from OneDrive camera roll as saved by OneDrive iOS app."
Write-Output "(C) 2024 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'
Write-Output "Getting OneDrive Authentication token..."
$auth=Get-ODPhotosToken
Write-Output "Downloading Live Photos..."
Write-Output $auth
Download-LivePhotosAuth -AccessToken $auth.'AccessToken-OneDrive.ReadWrite' -PathToScan $PathToScan -SaveTo $SaveTo