This commit is contained in:
tigeren 2024-12-23 11:04:57 +08:00
commit 5109e1ec3b
4 changed files with 628 additions and 0 deletions

281
Download-ODLivePhotos.ps1 Normal file
View File

@ -0,0 +1,281 @@
<#
.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-Output("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

334
Download-ODLivePhotosV2.ps1 Normal file
View File

@ -0,0 +1,334 @@
<#
.DESCRIPTION
Debug script for accessing OneDrive Live Photos using Microsoft Graph API.
Requires Microsoft.Graph PowerShell module.
.PARAMETER SaveTo
Target path where to save live photos.
.PARAMETER PathToScan
Path on OneDrive to scan (default: '/Photos/Camera Roll')
.EXAMPLE
.\Debug-ODLivePhotos.ps1 -SaveTo 'C:\Live Photos' -PathToScan '/Photos/Camera Roll'
#>
param (
[Parameter(Mandatory)]
[string] $SaveTo,
[string] $PathToScan = '/Photos/Camera Roll',
[string] $ClientId,
[string] $TenantId
)
# Check for Microsoft.Graph module and install if needed
if (-not (Get-Module -ListAvailable -Name Microsoft.Graph)) {
Write-Host "Installing Microsoft.Graph module..."
Install-Module Microsoft.Graph -Scope CurrentUser -Force
}
function Connect-GraphWithDebug {
try {
# Required scopes for OneDrive access
$scopes = @(
"Files.Read.All",
"Files.ReadWrite.All",
"offline_access"
)
Write-Host "Connecting to Microsoft Graph..."
Connect-MgGraph -Scopes $scopes
# Get and display current connection info
$context = Get-MgContext
Write-Host "Connected as: $($context.Account)"
Write-Host "Scopes: $($context.Scopes -join ', ')"
return $true
}
catch {
Write-Error "Failed to connect to Microsoft Graph: $_"
return $false
}
}
function Get-DriveItems {
param (
[string]$FolderPath
)
try {
# Verify we're connected
$context = Get-MgContext
if ($null -eq $context) {
throw "Not connected to Microsoft Graph"
}
# Get drive info
$drive = Get-MgDrive -Filter "driveType eq 'personal'" | Select-Object -First 1
Write-Host "Using Drive ID: $($drive.Id)"
# Clean up the path format
$cleanPath = $FolderPath.Replace('\', '/').Trim('/')
Write-Host "Searching in path: /$cleanPath"
try {
# First try to get the folder itself
$folder = Get-MgDriveItem -DriveId $drive.Id -DriveItemId "root:/$cleanPath" -ErrorAction Stop
Write-Host "Found folder: $($folder.Name)"
# Then get its children
$items = Get-MgDriveItemChild -DriveId $drive.Id -DriveItemId $folder.Id
Write-Host "Found $($items.Count) items in specified folder"
# Debug output for first few items
$items | Select-Object -First 50000 | ForEach-Object {
Write-Host "`nItem: $($_.Name)"
Write-Host "Type: $($_.File.MimeType)"
Write-Host "ID: $($_.Id)"
# Additional debug info for photos
if ($_.Photo) {
Write-Host "Photo metadata found!"
$_.Photo | Format-List | Out-String | Write-Host
}
}
return $items
}
catch {
Write-Error "Error accessing path '/$cleanPath': $_"
Write-Host "Full error details:"
$_ | Format-List -Force
return $null
}
}
catch {
Write-Error "Error in Get-DriveItems: $_"
Write-Host "Full error details:"
$_ | Format-List -Force
return $null
}
}
function Test-DownloadItem {
param (
[Parameter(Mandatory)]
[string]$ItemId,
[Parameter(Mandatory)]
[string]$SavePath,
[Parameter(Mandatory)]
$item
)
try {
$fileName = Join-Path $SavePath $item.Name
# Check if file already exists
if (Test-Path $fileName) {
Write-Host "File already exists: $fileName" -ForegroundColor Yellow
return $true
}
Write-Host "Downloading to: $fileName"
Get-MgDriveItemContent -DriveId $drive.Id -DriveItemId $ItemId -OutFile $fileName
if (Test-Path $fileName) {
Write-Host "Successfully downloaded: $fileName" -ForegroundColor Green
return $true
}
return $false
}
catch {
Write-Error "Download failed: $_"
return $false
}
}
function Get-LivePhotoBundle {
param (
[Parameter(Mandatory)]
$item,
[Parameter(Mandatory)]
$drive
)
try {
if ($item.File.MimeType -eq "image/heic") {
Write-Host "Found HEIC file: $($item.Name)" -ForegroundColor Cyan
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($item.Name)
# Get current auth context and token
$context = Get-MgContext
if (-not $context) {
throw "No authentication context found"
}
$token = $context.AccessToken
Write-Host "Using token: $($token.Substring(0, 10))..." -ForegroundColor Gray
# Get the item details with special fields
$itemEndpoint = "https://graph.microsoft.com/v1.0/drives/$($drive.Id)/items/$($item.Id)"
Write-Host "Getting item details..."
$headers = @{
'Authorization' = "Bearer $token"
'Accept' = 'application/json'
}
# First try to get item metadata
try {
$response = Invoke-MgGraphRequest -Uri $itemEndpoint -Method GET
Write-Host "Got item metadata"
# Try to get the video component using Graph API
$videoEndpoint = "$itemEndpoint/content"
$videoPath = Join-Path $SaveTo "$baseName.mov"
if (-not (Test-Path $videoPath)) {
Write-Host "Attempting to download video component..."
# Try different content types
$videoHeaders = @{
'Authorization' = "Bearer $token"
'Accept' = 'video/quicktime'
'Prefer' = 'respond-async'
}
# Try with special query parameters
$queryParams = @(
"select=video",
"expand=video",
"format=mov"
)
foreach ($param in $queryParams) {
$tryUrl = "${videoEndpoint}?$param"
Write-Host "Trying URL: $tryUrl"
try {
Invoke-MgGraphRequest -Uri $tryUrl -Headers $videoHeaders -Method GET -OutputFilePath $videoPath
if ((Test-Path $videoPath) -and (Get-Item $videoPath).Length -gt 0) {
Write-Host "Successfully downloaded video component" -ForegroundColor Green
return $true
}
}
catch {
Write-Host "Attempt failed with $param : $_" -ForegroundColor Yellow
Remove-Item $videoPath -ErrorAction SilentlyContinue
}
}
# If all attempts failed, try one last time with direct Graph API call
try {
$finalEndpoint = "$itemEndpoint/video"
Write-Host "Trying final endpoint: $finalEndpoint"
Invoke-MgGraphRequest -Uri $finalEndpoint -Method GET -OutputFilePath $videoPath
if ((Test-Path $videoPath) -and (Get-Item $videoPath).Length -gt 0) {
Write-Host "Successfully downloaded video using final attempt" -ForegroundColor Green
return $true
}
}
catch {
Write-Host "Final attempt failed: $_" -ForegroundColor Red
Remove-Item $videoPath -ErrorAction SilentlyContinue
}
} else {
Write-Host "Video file already exists: $videoPath" -ForegroundColor Yellow
return $true
}
}
catch {
Write-Host "Failed to get item metadata: $_" -ForegroundColor Red
}
}
return $false
}
catch {
Write-Error "Error processing potential Live Photo: $_"
return $false
}
}
# Add this helper function to get a Photos-specific token if needed
function Get-ODPhotosToken {
param(
[string]$ClientId = "073204aa-c1e0-4e66-a200-e5815a0aa93d" # OneDrive Photos client ID
)
$scopes = "OneDrive.ReadWrite offline_access"
$redirectUri = "https://photos.onedrive.com/auth/login"
# Get token using device code flow
$deviceCode = Get-MgDeviceCode -ClientId $ClientId -Scopes $scopes
Write-Host "Please visit: $($deviceCode.VerificationUri)"
Write-Host "Enter code: $($deviceCode.UserCode)"
$token = Get-MgToken -DeviceCode $deviceCode -ErrorAction Stop
return $token.AccessToken
}
# Main execution
Write-Host "OneDrive Live Photos Debug Script"
Write-Host "Save location: $SaveTo"
Write-Host "Scan path: $PathToScan"
# Create save directory if it doesn't exist
if (!(Test-Path $SaveTo)) {
New-Item -ItemType Directory -Force -Path $SaveTo
}
# Connect to Graph API
if (Connect-GraphWithDebug) {
Write-Host "`nQuerying OneDrive items..."
$items = Get-DriveItems -FolderPath $PathToScan
# print the items to the console
# $items | Format-List | Out-String | Write-Host
if ($items) {
# Get drive reference
$drive = Get-MgDrive -Filter "driveType eq 'personal'" | Select-Object -First 1
if (-not $drive) {
Write-Error "Could not get drive reference"
return
}
Write-Host "`nProcessing items for Live Photos..."
foreach ($item in $items) {
Write-Host "`nChecking item: $($item.Name)"
# Process only HEIC files
if ($item.File.MimeType -eq "image/heic") {
Write-Host "Found HEIC file, checking for Live Photo components..."
# Download the HEIC file if it doesn't exist
try {
$result = Test-DownloadItem -ItemId $item.Id -SavePath $SaveTo -item $item
if ($result) {
# Try to get the video component
$hasVideo = Get-LivePhotoBundle -item $item -drive $drive
if ($hasVideo) {
Write-Host "Successfully processed Live Photo bundle for: $($item.Name)" -ForegroundColor Green
} else {
Write-Host "No Live Photo video component found for: $($item.Name)" -ForegroundColor Yellow
}
}
}
catch {
Write-Host "Error processing $($item.Name): $_" -ForegroundColor Red
}
# Add a small delay between items
Start-Sleep -Milliseconds 500
}
}
Write-Host "`nCompleted processing Live Photos"
} else {
Write-Host "No items found to process"
}
}
Write-Host "`nDebug script completed"

1
Download.ps1 Normal file
View File

@ -0,0 +1 @@
.\Download-ODLivePhotos.ps1 -SaveTo 'D:\Photos\OneDrive-Photo\2024\12' -PathToScan '\Photos\Auto-saved\2024\12'

12
DownloadV2.ps1 Normal file
View File

@ -0,0 +1,12 @@
# Example values - you need to replace these with your own from Azure Portal
$params = @{
SaveTo = "C:\Temp\2024\12"
PathToScan = "/Photos/Camera Roll"
ClientId = "d581ab07-3a21-44d3-84c4-16b06bef6266" # From Azure App Registration
TenantId = "common" # Use "common" for personal Microsoft accounts
}
# '\Photos\Auto-saved\2024\12'
# /Photos/Camera Roll
.\Download-ODLivePhotosV2.ps1 -SaveTo 'C:\Temp\2024\12' -PathToScan '\Photos\Auto-saved\2024\12' -ClientId 'd581ab07-3a21-44d3-84c4-16b06bef6266' -TenantId 'common'