Uploading Photos to Azure Storage

A few days ago, I had a mission: back up my kids’ photos. There were about 5000 of them, taking up around 2.5 GB of space. I had just downloaded them all from Lyfle, and now I wanted to store them safely in the cloud.

At first, I thought about using OneDrive. But then I realized that it might fill up quickly, and I didn’t want to run into storage issues later.

A few weeks ago, I had studied Azure Storage Accounts, as part of Azure Fundamentals certification, so I decided to use that instead. It felt like a good opportunity to try it out. Since I wanted to keep costs low, I chose the most affordable options:

  • LRS (Locally Redundant Storage): This stores your data three times in one data center. It’s not the most secure option if something happens to that data center, but it’s fine for backing up personal photos.
  • Cold Tier: This is designed for data that you don’t need to access often. Uploading costs a bit more, but storing is cheaper. Since I won’t be downloading these photos regularly, it’s a good fit.

I created the Storage Account manually using the Azure Portal. I selected Blob storage, which is suitable for unstructured data like images. I also wanted to keep the same folder structure as on my laptop, where each month has its own folder named with “YYYY-MM” format like “2025-01”, “2025-02”, etc. So I planned to create containers in Azure with the same names.

With so many folders and photos, doing this manually in the portal didn’t seem practical, and of course, boring!. I wanted to automate the process.

I used GitHub Copilot to help me generate a script that would upload the photos to Azure Blob Storage and keep the folder structure. Here’s the code Copilot gave me:

using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
using System;
using System.IO;
using System.Threading.Tasks;
using System.Text.RegularExpressions;

public class LyflePhotosUploader
{
    private readonly BlobServiceClient _blobServiceClient;
    private readonly string _localFolderPath;

    public LyflePhotosUploader(string connectionString, string localFolderPath)
    {
        _blobServiceClient = new BlobServiceClient(connectionString);
        _localFolderPath = localFolderPath;
    }

    public async Task UploadPhotosAsync()
    {
        try
        {
            Console.WriteLine("Starting upload process...");

            // Get all YYYY-MM subdirectories
            var subdirectories = Directory.GetDirectories(_localFolderPath);
            
            foreach (var subdirectory in subdirectories)
            {
                var folderName = Path.GetFileName(subdirectory);
                
                // Validate YYYY-MM format
                if (!IsValidYearMonthFormat(folderName))
                {
                    Console.WriteLine($"Skipping folder '{folderName}' - doesn't match YYYY-MM format");
                    continue;
                }

                Console.WriteLine($"Processing folder: {folderName}");

                // Create or get container
                var containerName = folderName.ToLowerInvariant(); // Container names must be lowercase
                var containerClient = await CreateContainerAsync(containerName);

                // Upload all photos in this folder
                await UploadPhotosFromFolderAsync(containerClient, subdirectory);
            }

            Console.WriteLine("Upload process completed!");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error during upload: {ex.Message}");
            throw;
        }
    }

    private async Task<BlobContainerClient> CreateContainerAsync(string containerName)
    {
        try
        {
            var containerClient = _blobServiceClient.GetBlobContainerClient(containerName);
            
            // Create container if it doesn't exist
            await containerClient.CreateIfNotExistsAsync(PublicAccessType.None);
            
            Console.WriteLine($"Container '{containerName}' ready");
            return containerClient;
        }
        catch (Exception ex)
        {
            Console.WriteLine($"Error creating container '{containerName}': {ex.Message}");
            throw;
        }
    }

    private async Task UploadPhotosFromFolderAsync(BlobContainerClient containerClient, string folderPath)
    {
        // Common photo file extensions
        var photoExtensions = new[] { ".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".webp", ".heic", ".raw" };
        
        var files = Directory.GetFiles(folderPath, "*.*", SearchOption.AllDirectories);
        var photoFiles = Array.FindAll(files, file => 
            Array.Exists(photoExtensions, ext => 
                Path.GetExtension(file).Equals(ext, StringComparison.OrdinalIgnoreCase)));

        Console.WriteLine($"Found {photoFiles.Length} photo files to upload");

        foreach (var photoFile in photoFiles)
        {
            try
            {
                await UploadSinglePhotoAsync(containerClient, photoFile, folderPath);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Failed to upload '{photoFile}': {ex.Message}");
                // Continue with other files
            }
        }
    }

    private async Task UploadSinglePhotoAsync(BlobContainerClient containerClient, string filePath, string baseFolderPath)
    {
        // Create blob name maintaining subfolder structure if any
        var relativePath = Path.GetRelativePath(baseFolderPath, filePath);
        var blobName = relativePath.Replace('\\', '/'); // Use forward slashes for blob names

        var blobClient = containerClient.GetBlobClient(blobName);

        // Check if blob already exists
        if (await blobClient.ExistsAsync())
        {
            Console.WriteLine($"Skipping '{blobName}' - already exists");
            return;
        }

        using var fileStream = File.OpenRead(filePath);
        
        // Upload with Cold access tier
        var uploadOptions = new BlobUploadOptions
        {
            AccessTier = AccessTier.Cold,
            Metadata = new Dictionary<string, string>
            {
                { "OriginalPath", filePath },
                { "UploadDate", DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") },
                { "FileSize", new FileInfo(filePath).Length.ToString() }
            }
        };

        await blobClient.UploadAsync(fileStream, uploadOptions);
        
        Console.WriteLine($"Uploaded: {blobName} ({FormatFileSize(new FileInfo(filePath).Length)})");
    }

    private static bool IsValidYearMonthFormat(string folderName)
    {
        // Match YYYY-MM format (e.g., 2023-01, 2024-12)
        var regex = new Regex(@"^\d{4}-\d{2}$");
        return regex.IsMatch(folderName);
    }

    private static string FormatFileSize(long bytes)
    {
        string[] suffixes = { "B", "KB", "MB", "GB" };
        int counter = 0;
        decimal number = bytes;
        
        while (Math.Round(number / 1024) >= 1)
        {
            number /= 1024;
            counter++;
        }
        
        return $"{number:n1} {suffixes[counter]}";
    }
}

And this is how I call that code.

// Replace with your Azure Storage connection string
// If you don't know how to get it, go to your Storage Accounts > Security + Networking > Access Keys
// then click show on the Connection string
const string connectionString = "";

// Your local folder path
const string localFolderPath = @"C:\Users\oshinyil\Pictures\Lyfle Photos";

try
{
    var uploader = new LyflePhotosUploader(connectionString, localFolderPath);
    await uploader.UploadPhotosAsync();
}
catch (Exception ex)
{
    Console.WriteLine($"Application error: {ex.Message}");
    Console.WriteLine("Press any key to exit...");
    Console.ReadKey();
}

I was surprised that the script worked perfectly. No bugs found!

Now that the photos are backed up in the cloud, I want to monitor how much I’m spending each month. Hopefully it doesn’t eat up my $150 monthly credit :D