Add scripts for downloading Live Photos from OneDrive and syncing files

This commit is contained in:
Tiger Ren 2025-04-04 01:02:54 +08:00
parent 4f547af149
commit cfdb93a91e
11 changed files with 1116 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-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

348
Download-ODLivePhotosV2.ps1 Normal file
View File

@ -0,0 +1,348 @@
<#
.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

1
Download.ps1 Normal file
View File

@ -0,0 +1 @@
.\Download-ODLivePhotosV2.ps1 -SaveTo 'D:\Photos\OneDrive-Photo\2025\04' -PathToScan '\Photos\Auto-saved\2025\04' -AccessToken 'bearer EwAIBK1DBAAUnIIyw3fu//VwydnOZRl0mzVDtSEAAZEJfUS4ycN7p2NsXfOVvL8URvOVZ/IJfhK6zremxPOoWwvBNw94siJNRuMahNWVE0nHlmojzGY5W9U1yZ46wt8LhbuHGp7flzFmctyWNiVjOSv6DFHwqxeR2MNWp/fJw7wAgOoqyvYnX0M/Fi4fn3xCwYQKEGQIPfTAA0bo0BY/s1B2sNjGxJHnoHO%2bpmJPAQkOzBroyzL0XDmacrg8Yq73vwfpoAKrldY2c8U4IRLp56n4IY5Dkm/IT7jvseV5zpWBZ7kGPT7bvQEJny3FLyXhRcn/tJWUcaygTi%2b8Ag86CNx/1HIfmY0PxgDgM/bGMTCACeSwn1IrDqXtuHxuXQ8QZgAAEBGJudb6NkqUwifou2E5lUnQAokpbHEJ4NRBDgYygdm/iue5F8RHd25td4fbhK7rMtqGG2/qDcCG3Zc/l8bq%2bpJO1i/K/r4RCeekeYV8KpFsqH2VHOPX2bkl6wFNh34OtDYlFMyj2aGqDp9Q4kRPjf9MEDxM6Upv2FNZtccjT9HV25nyPOAAPmc4JBY7bUFRYlky60yKH0gg%2br0ybqPEN0/e2voTPAZvZR6LYmb7BtFHW6dfTU7/c56NTEOvvv2ae4WDoxacQ9C4vAaCEiY36NS6i3jT5qhu/2Aj3uxCGJ0CsNNPgAZa384h/nQ4iv3gbtSGy/x5SAkK3yxxvkMOepQpU16HOSVPgE5v0pSFWwny9O/H5d6eG7jDWf%2bbyLdJUcFtUi5ROmkm5uT3LObUsqL0yZSyRRkGwGGcv%2b6QdcoAj%2bxwalHVyfebRa0iDZFDbI1VmplIGkJIoranZDQxafkH7m8f4mMIoh%2bZ7h/MX1DCXnY2COJqc9zWZCcsHk2Sx8UAtiCINKPUCiQp1VIw2CDnVDLIO866TuSdDtIqTfTeS9RkSeuGVK8sx9Ezttsbyo97fq2ocLirCXmYRVuh4JvgmFIDLwLwNv3ZgrSUjxOY/elG/QVV%2bwHHG9TfeF7d4UaYdlNIcRnx/%2bF6S1O%2bVKiWrCAFBVqVxiN8AmEl2BQ79lkKkOIngapKUgfWklEEUuYfyDkaVyqBljyXFCY6K6SsQM7FRCr9mtwy3PmP6NFKkKaZnn5DvcR6xad/wHHXNLFSfm4JzsiEtmO19m/EnUZzzuQMvl3oybqtl0Ojp/SnFH6u48PXKvFDVdXjW4I8Q9eMMqCJqkMxmbI64bmX/rPlCJbsZjro3FcjGQB1dpHIE9d%2b00816sydZ1NSc%2bp78reRAQ8P2xr0/A9tao6a23yzAp8FAWpuXhNVgdwNElBkB7rqs60lLDx/NXUkRes0sTnacz9NpeBst04g/P7As2qvSvEC'

172
download_od_livephotos.py Normal file
View File

@ -0,0 +1,172 @@
import os
import requests
import webbrowser
import http.server
import socketserver
import threading
import urllib.parse
from datetime import datetime
from typing import Optional, Dict
import uuid
import tkinter as tk
from tkinter import ttk
from webview import create_window, start
class OAuthCallbackHandler(http.server.SimpleHTTPRequestHandler):
def do_POST(self):
content_length = int(self.headers['Content-Length'])
post_data = self.rfile.read(content_length).decode('utf-8')
self.server.oauth_response = post_data
self.send_response(200)
self.send_header('Content-type', 'text/html')
self.end_headers()
self.wfile.write(b"Authentication complete. You can close this window.")
def log_message(self, format, *args):
# Suppress logging
pass
def get_onedrive_token() -> Dict[str, str]:
"""
Authenticate with OneDrive using the Photos web client ID.
Returns authentication tokens in a dictionary.
"""
client_id = "073204aa-c1e0-4e66-a200-e5815a0aa93d"
redirect_uri = "https://photos.onedrive.com/auth/login"
scope = "OneDrive.ReadWrite,offline_access,openid,profile"
# 创建一个简单的 Web 浏览器窗口
import tkinter as tk
from tkinter import ttk
from webview import create_window, start
def on_loaded():
# 监听 cookies 变化
cookies = webview.get_cookies()
for cookie in cookies:
if cookie['name'] == 'AccessToken-OneDrive.ReadWrite':
window.destroy()
return cookie['value']
auth_url = (
"https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize"
f"?client_id={client_id}"
f"&nonce=uv.{str(uuid.uuid4())}"
"&response_mode=form_post"
f"&scope={scope}"
"&response_type=code"
f"&redirect_uri={urllib.parse.quote(redirect_uri)}"
)
# 创建浏览器窗口
window = create_window('OneDrive Authentication', auth_url,
width=660, height=775)
# 启动窗口并等待认证完成
start(func=on_loaded)
return window.get_cookies()
def download_live_photos(access_token: str, save_to: str, path_to_scan: str = "/Pictures/Camera Roll"):
"""
Download all Live Photos from the specified OneDrive path.
"""
if not path_to_scan.startswith('/'):
path_to_scan = '/' + path_to_scan
if not path_to_scan.endswith('/'):
path_to_scan += '/'
def process_folder(folder_id: str = 'root', current_path: str = '/'):
url = f"https://api.onedrive.com/v1.0/drive/items/{folder_id}/children"
params = {
"$filter": "photo/livePhoto ne null or folder ne null or remoteItem ne null",
"select": "fileSystemInfo,photo,id,name,size,folder,remoteItem"
}
headers = {"Authorization": f"Bearer {access_token}"}
while url:
response = requests.get(url, params=params, headers=headers).json()
for item in response.get('value', []):
folder_path = current_path + item['name'] + '/'
# Handle folders
if 'folder' in item:
if folder_path.startswith(path_to_scan) or path_to_scan.startswith(folder_path):
print(f"Checking folder {item['id']} - {folder_path}")
process_folder(item['id'], folder_path)
# Handle live photos
if 'photo' in item and 'livePhoto' in item['photo']:
if current_path.startswith(path_to_scan):
target_path = os.path.join(save_to, current_path[len(path_to_scan):])
download_single_live_photo(
access_token,
item['id'],
target_path,
item['size'],
item['fileSystemInfo']['lastModifiedDateTime']
)
url = response.get('@odata.nextLink')
params = {} # Clear params as nextLink includes them
process_folder()
def download_single_live_photo(access_token: str, element_id: str, save_to: str,
expected_size: int, last_modified: str):
"""
Download a single Live Photo (both video and image components).
"""
os.makedirs(save_to, exist_ok=True)
headers = {"Authorization": f"Bearer {access_token}"}
actual_size = 0
# Download video component
video_url = f"https://api.onedrive.com/v1.0/drive/items/{element_id}/content?format=video"
video_response = requests.get(video_url, headers=headers)
if video_response.ok:
filename = video_response.headers.get('Content-Disposition', '').split('filename=')[-1].strip('"')
if filename:
video_path = os.path.join(save_to, filename)
with open(video_path, 'wb') as f:
f.write(video_response.content)
actual_size += len(video_response.content)
os.utime(video_path, (datetime.fromisoformat(last_modified.replace('Z', '+00:00')).timestamp(),) * 2)
# Download image component
image_url = f"https://api.onedrive.com/v1.0/drive/items/{element_id}/content"
image_response = requests.get(image_url, headers=headers)
if image_response.ok:
filename = image_response.headers.get('Content-Disposition', '').split('filename=')[-1].strip('"')
if filename:
image_path = os.path.join(save_to, filename)
with open(image_path, 'wb') as f:
f.write(image_response.content)
actual_size += len(image_response.content)
os.utime(image_path, (datetime.fromisoformat(last_modified.replace('Z', '+00:00')).timestamp(),) * 2)
if actual_size != expected_size:
print(f"Warning: Size mismatch for {element_id}. Expected {expected_size}, got {actual_size}")
def main():
print("Live Photo downloader - Downloads Live Photos from OneDrive camera roll")
print("(C) 2024 Petr Vyskocil. Licensed under MIT license.")
print()
save_to = input("Enter the path to save Live Photos: ")
path_to_scan = input("Enter OneDrive path to scan [/Photos/Auto-saved]: ") or "/Photos/Auto-saved"
print("Getting OneDrive Authentication token...")
auth = get_onedrive_token()
if 'access_token' not in auth:
print("Failed to get authentication token")
return
print("Downloading Live Photos...")
download_live_photos(auth['access_token'], save_to, path_to_scan)
if __name__ == "__main__":
main()

103
run_ps_download.py Normal file
View File

@ -0,0 +1,103 @@
import subprocess
import sys
import argparse
from datetime import datetime, timedelta
from pathlib import Path
def run_download_script(base_save_to: str, base_path_to_scan: str, target_date: datetime) -> bool:
"""
Execute the Download-ODLivePhotos.ps1 PowerShell script with year/month paths
Args:
base_save_to: Base destination path
base_path_to_scan: Base source path in OneDrive
target_date: Date to determine year/month
"""
# Format year and month
year = target_date.strftime("%Y")
month = target_date.strftime("%m")
# Construct full paths
save_to = str(Path(base_save_to) / year / month)
path_to_scan = str(Path(base_path_to_scan) / year / month)
try:
# Get the script path (assuming it's in the same directory)
script_path = Path(__file__).parent / "Download-ODLivePhotosV2.ps1"
# Construct the PowerShell command
cmd = [
'powershell.exe',
'-NoProfile',
'-ExecutionPolicy', 'Bypass',
'-File', str(script_path),
'-SaveTo', save_to,
'-PathToScan', path_to_scan
]
print(f"Processing {year}/{month}")
print(f"Executing: {' '.join(cmd)}")
result = subprocess.run(
cmd,
capture_output=True,
text=True,
check=True
)
if result.stdout:
print("Output:")
print(result.stdout)
return True
except subprocess.CalledProcessError as e:
print(f"Error executing PowerShell script:", file=sys.stderr)
print(f"Exit code: {e.returncode}", file=sys.stderr)
if e.stdout:
print("Output:", file=sys.stderr)
print(e.stdout, file=sys.stderr)
if e.stderr:
print("Error:", file=sys.stderr)
print(e.stderr, file=sys.stderr)
return False
# Process current month (and last month if today is the 1st)
# python run_ps_download.py --base-save-to "D:\Photos\OneDrive-Photo" --base-path-to-scan "\Photos\Auto-saved"
# Process specific month (2 for last month, 3 for month before last, etc.)
# python run_ps_download.py --base-save-to "D:\Photos\OneDrive-Photo" --base-path-to-scan "\Photos\Auto-saved" --months 2
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Execute Download-ODLivePhotos.ps1')
parser.add_argument('--base-save-to', required=True,
help='Base destination path (year/month will be appended)')
parser.add_argument('--base-path-to-scan', required=True,
help='Base source path in OneDrive (year/month will be appended)')
parser.add_argument('--months', type=int, default=0,
help='Optional: specific month to process (1: current month, 2: last month, etc.). '
'If not set: processes current month, plus last month if today is first day of month')
args = parser.parse_args()
# Get current date
now = datetime.now()
if args.months > 0:
# Specific month was requested
months_ago = args.months - 1
target_date = now.replace(day=1) - timedelta(days=months_ago * 28)
success = run_download_script(args.base_save_to, args.base_path_to_scan, target_date)
else:
# Default behavior
# Always process current month
success = run_download_script(args.base_save_to, args.base_path_to_scan, now)
# If it's the first day of the month, also process last month
if now.day == 1:
last_month = now.replace(day=1) - timedelta(days=1)
success = success and run_download_script(args.base_save_to, args.base_path_to_scan, last_month)
sys.exit(0 if success else 1)

29
scan_lib.py Normal file
View File

@ -0,0 +1,29 @@
import requests
import sys
def scan_library():
"""
Scan library using Jellyfin API
"""
url = 'http://192.168.2.216:2283/api/libraries/8ea04568-fd60-4960-8b54-11e7508af1c4/scan'
headers = {
'x-api-key': 'vIs1N0FLaPDCQz6CILc3fBzC7IcRgG5Tne4fyMrA'
}
try:
response = requests.post(url, headers=headers)
response.raise_for_status() # Raise an exception for bad status codes
print(f"Scan initiated successfully. Status code: {response.status_code}")
if response.text:
print("Response:", response.text)
return True
except requests.exceptions.RequestException as e:
print(f"Error scanning library: {e}", file=sys.stderr)
return False
if __name__ == "__main__":
success = scan_library()
sys.exit(0 if success else 1)

2
scan_lib.sh Normal file
View File

@ -0,0 +1,2 @@
curl -L -X POST 'http://192.168.2.216:2283/api/libraries/8ea04568-fd60-4960-8b54-11e7508af1c4/scan' \
-H 'x-api-key: vIs1N0FLaPDCQz6CILc3fBzC7IcRgG5Tne4fyMrA'

68
sync.py Normal file
View File

@ -0,0 +1,68 @@
import os
import shutil
from pathlib import Path
import sys
import argparse
def sync_files(src_path: str, dest_path: str) -> None:
"""
Synchronize files from source to destination directory, skipping existing files.
Args:
src_path: Source directory path
dest_path: Destination directory path
"""
# Convert to Path objects for easier handling
src = Path(src_path)
dest = Path(dest_path)
# Ensure source directory exists
if not src.exists():
raise FileNotFoundError(f"Source directory {src_path} does not exist")
# Create destination directory if it doesn't exist
dest.mkdir(parents=True, exist_ok=True)
# Walk through source directory
for root, _, files in os.walk(src):
# Convert current root to Path object
root_path = Path(root)
# Calculate relative path from source
rel_path = root_path.relative_to(src)
# Create corresponding destination directory
dest_dir = dest / rel_path
dest_dir.mkdir(parents=True, exist_ok=True)
# Copy each file
for file in files:
src_file = root_path / file
dest_file = dest_dir / file
# Skip if destination file exists
if dest_file.exists():
print(f"Skipping existing file: {dest_file}")
continue
# Copy the file
print(f"Copying: {src_file} -> {dest_file}")
shutil.copy2(src_file, dest_file)
# Usage
# python sync.py "C:\Users\petr\OneDrive\Photos\Auto-saved\2024\11" "D:\Photos\OneDrive-Photo\2024\11"
# python sync.py /path/to/source /path/to/destination
if __name__ == "__main__":
# Set up argument parser
parser = argparse.ArgumentParser(description='Synchronize files from source to destination directory')
parser.add_argument('src', help='Source directory path')
parser.add_argument('dest', help='Destination directory path')
# Parse arguments
args = parser.parse_args()
try:
sync_files(args.src, args.dest)
except Exception as e:
print(f"Error: {e}", file=sys.stderr)
sys.exit(1)

36
sync.py.bak Normal file
View File

@ -0,0 +1,36 @@
import os
import shutil
from pathlib import Path
def sync_heic_mov(src_folder, dest_folder):
# Convert to Path objects for easier handling
src_path = Path(src_folder)
dest_path = Path(dest_folder)
# Walk through the source directory
for root, dirs, files in os.walk(src_path):
for file in files:
if file.lower().endswith('.mov'):
# Get the relative path from the source folder
rel_path = Path(root).relative_to(src_path)
# Construct the full paths for source and destination
src_file = Path(root) / file
dest_file = dest_path / rel_path / file
# Create the destination directory if it doesn't exist
dest_file.parent.mkdir(parents=True, exist_ok=True)
# Check if the destination file already exists
if not dest_file.exists():
print(f"Copying: {src_file} -> {dest_file}")
shutil.copy2(src_file, dest_file)
else:
print(f"Skipping (already exists): {dest_file}")
if __name__ == "__main__":
source_folder = input("Enter the source folder path: ")
destination_folder = input("Enter the destination folder path: ")
sync_heic_mov(source_folder, destination_folder)
print("Sync completed.")

76
sync_task.py Normal file
View File

@ -0,0 +1,76 @@
import os
from datetime import datetime, timedelta
import subprocess
import argparse
from pathlib import Path
def sync_monthly(base_src: str, base_dest: str, date: datetime) -> None:
"""
Sync files for a specific year/month from base_src to base_dest
Args:
base_src: Base source directory
base_dest: Base destination directory
date: Date to process
"""
# Format year and month
year = date.strftime("%Y")
month = date.strftime("%m")
# Construct full paths
src_path = Path(base_src) / year / month
dest_path = Path(base_dest) / year / month
# Skip if source doesn't exist
if not src_path.exists():
print(f"Source path does not exist: {src_path}")
return
print(f"Syncing {src_path} -> {dest_path}")
# Call sync.py
# Assuming sync.py is in the same directory as this script
sync_script = Path(__file__).parent / "sync.py"
try:
subprocess.run([
"python",
str(sync_script),
str(src_path),
str(dest_path)
], check=True)
except subprocess.CalledProcessError as e:
print(f"Sync failed for {year}/{month}: {e}")
# Sync OneDrive to Local(Backup)
# python sync_task.py d:\OneDrive\Photos\Auto-saved\ D:\Photos\Auto-saved --months 1
# Sync Photo to NAS
# python sync_task.py d:\OneDrive\Photos\Auto-saved\ \\tiger-nas\Multimedia\Photos\ --months 1
# Sync Local Motion to NAS
# python sync_task.py D:\Photos\OneDrive-Photo\ \\tiger-nas\Multimedia\Photos\ --months 1
if __name__ == "__main__":
parser = argparse.ArgumentParser(description='Sync files based on year/month structure')
parser.add_argument('base_src', help='Base source directory')
parser.add_argument('base_dest', help='Base destination directory')
parser.add_argument('--months', type=int, default=0,
help='Optional: specific month to sync (1: current month, 2: last month, etc.). '
'If not set: syncs current month, plus last month if today is first day of month')
args = parser.parse_args()
# Get current date
now = datetime.now()
if args.months > 0:
# Specific month was requested
months_ago = args.months - 1
target_date = now.replace(day=1) - timedelta(days=months_ago * 28)
sync_monthly(args.base_src, args.base_dest, target_date)
else:
# Default behavior
# Always sync current month
sync_monthly(args.base_src, args.base_dest, now)
# If it's the first day of the month, also sync last month
if now.day == 1:
last_month = now.replace(day=1) - timedelta(days=1)
sync_monthly(args.base_src, args.base_dest, last_month)

0
sync_to_nas.py Normal file
View File