Dicres Photos Uploader

  • net
  • csharp
  • avalonia
  • google-photos
  • oauth
  • desktop-apps

Tengo mis fotos organizadas en carpetas desde hace años. Una carpeta por viaje, por boda, por cumpleaños… Cada carpeta, un recuerdo. El problema es que todo eso vivía únicamente en mi ordenador y en un disco duro externo, sin ninguna copia en la nube.

Quería subirlo todo a Google Photos, pero no de cualquier manera. Quería que cada carpeta se convirtiese en un álbum, respetando la organización que ya tenía. Rastreé la red en busca de alguna aplicación que hiciese exactamente eso y no encontré ninguna. Bueno, siendo sincero, tampoco busqué demasiado, porque así tenía la excusa perfecta para crear yo mismo la aplicación, con ayuda de la IA. Así nació Dicres Photos Uploader.

Qué hace la aplicación

Es una app de escritorio para Windows y macOS que ha nacido para cubrir la necesidad que antes he comentado.

  • A partir de una carpeta raiz una carpeta raíz. Cada subcarpeta directa se convierte en un álbum en Google Photos con el mismo nombre.
  • Sube todas las fotos y vídeos de esas subcarpetas al álbum correspondiente, saltándose los que ya estaban subidos.
  • Se puede lanzar bajo demanda, desde la propia interfaz, o dejarla programada para que se ejecute sola los días y horas que yo indique, usando el planificador nativo del sistema operativo (Task Scheduler en Windows, launchd en macOS).
  • Guarda el progreso en disco y reanuda exactamente donde lo dejó en cada ejecución, sin volver a subir nada que ya estuviese subido.
  • Si un archivo falla al subirse, no se reintenta indefinidamente. Se descarta, se copia a una carpeta de “errores” para revisarlo a mano, y queda registrado para no volver a intentarlo en la siguiente ejecución.

La tecnología elegida (y por qué)

  • .NET 10 y Avalonia: quería una única base de código en C# que funcionase tanto en Windows como en macOS, sin depender de Electron ni de reescribir nada por duplicado. Avalonia es un framework de UI basado en XAML muy parecido a WPF, así que, habiendo usado Xamarin y dotNet MAUI, me sentí como en casa desde el primer momento.
  • CommunityToolkit.Mvvm: para no escribir a mano todo el boilerplate de MVVM. Con [ObservableProperty] y [RelayCommand] me ahorro las implementaciones manuales de INotifyPropertyChanged e ICommand.
  • Google Photos Library API “a pelo”: no he encontrado un cliente .NET moderno y tipado para esta API, así que he tirado de HttpClient y System.Text.Json manual para hablar directamente con el servicio.
  • El planificador nativo del sistema operativo en lugar de un servicio propio corriendo en segundo plano. De esta forma no necesito un proceso residente consumiendo recursos todo el día, y me llevo gratis cosas como que el proceso se ejecute cuando esté disponible si a la hora de ejecución la maquina estaba apagada.
  • xUnit y GitHub Actions: para tener una batería de tests que se ejecuta en cada pull request y bloquear el merge a main si algo se rompe.

Las partes más “peliagudas” del código

Un estado que permite reanudar exactamente donde lo dejaste

Todo el progreso (qué álbumes existen ya, qué archivos están subidos, cuáles se han descartado) se guarda en un state.json que se relee y se guarda constantemente durante la subida, no solo al final. Así, si el proceso se interrumpe a media subida, la siguiente ejecución no repite trabajo.

Para evitar que un cierre inesperado (o que alguien mate el proceso) deje un state.json corrupto a medio escribir, tanto este fichero como la configuración y el histórico se guardan siempre a un archivo temporal y luego se renombra.

public void Save(AppConfig config)
{
    Directory.CreateDirectory(AppConfig.AppDataFolder);

    var json = JsonSerializer.Serialize(config, JsonOptions);
    var tmpPath = _path + ".tmp";
    File.WriteAllText(tmpPath, json);
    File.Move(tmpPath, _path, overwrite: true);
}

File.Move con overwrite: true es una operación atómica en los tres sistemas de archivos que me interesan, así que nunca puede quedar un fichero a medias. O está el antiguo, o está el nuevo, nunca algo roto entre medias.

Un único motor de subida para el modo interactivo y el modo programado

Desde el principio tuve claro que no quería mantener dos implementaciones distintas de “subir fotos”. Una para cuando le doy al botón “Ejecutar ahora” y otra para cuando la ejecuta el planificador en segundo plano sin ventana. UploadService.RunAsync es el único punto de entrada, y lo llaman por igual la interfaz y el modo headless:

public async Task<UploadRunSummary> RunAsync(
    AppConfig appConfig,
    StateStore stateStore,
    AppState state,
    IProgress<string> log,
    CancellationToken ct,
    IProgress<AlbumUploadProgress>? albumProgress = null)
{
    var context = new RunContext(appConfig, stateStore, state, log, ct, albumProgress, "Upload_Cancelled");

    if (!Directory.Exists(appConfig.RootFolder))
        return AbortMissingFolder(context, Loc.Format("Upload_RootFolderMissing", appConfig.RootFolder));

    ResetDailyCounterIfNewDay(state);

    try
    {
        await UploadPendingAlbumsAsync(context);

        context.SaveState();
        ReportUploadSummary(context);
        return context.BuildSummary(success: true, quotaExceeded: false, errorMessage: null);
    }
    catch (Exception ex)
    {
        return HandleRunException(context, ex);
    }
}

Que este método no dependa de nada de la interfaz gráfica (ni siquiera sabe que Avalonia existe) es lo que permite que el modo programado, que se ejecuta sin ninguna ventana, reutilice exactamente la misma lógica que el botón de la interfaz, sin duplicar una sola línea.

Subir en lotes contra la API de Google Photos

La API de Google Photos separa la subida en dos pasos. Primero subes los bytes de cada archivo y obtienes un “upload token” por cada uno, y luego, en una única llamada por lote, confirmas hasta 50 de esos tokens a la vez para que pasen a formar parte del álbum. Lo interesante es que esa llamada de confirmación puede aceptar unos archivos del lote y rechazar otros, así que hay que revisar el resultado archivo por archivo.

public async Task<List<BatchItemResult>> BatchCreateMediaItemsAsync(
    string albumId,
    List<(string FilePath, string UploadToken)> items)
{
    // ... construcción y envío de la petición ...

    var results = new List<BatchItemResult>();
    for (var i = 0; i < items.Count; i++)
    {
        var fileName = Path.GetFileName(items[i].FilePath);
        var result = parsed.NewMediaItemResults?.ElementAtOrDefault(i);

        var statusCode = result?.Status?.Code ?? 0; // 0 = OK en el enum google.rpc.Code
        if (statusCode == 0 && result?.MediaItem?.Id is not null)
            results.Add(new BatchItemResult { FileName = fileName, Success = true, MediaItemId = result.MediaItem.Id });
        else
            results.Add(new BatchItemResult { FileName = fileName, Success = false, ErrorMessage = result?.Status?.Message });
    }

    return results;
}

Así, un archivo corrupto o rechazado por Google no tira abajo el resto del lote: cada uno se marca como subido o como fallido de forma independiente, y solo los fallidos acaban en la carpeta de errores.

Evitar que dos ejecuciones se pisen

Una ejecución manual, un “reprocesar errores” y una ejecución programada pueden, en teoría, dispararse casi a la vez, y cada una puede vivir en un proceso distinto (la programada la lanza el propio sistema operativo). Necesitaba una exclusión mutua entre procesos, no solo entre threads del mismo proceso, así que descarté Mutex/Semaphore con nombre (esta última ni siquiera está soportada entre procesos en macOS) y usé un bloqueo exclusivo de archivo.

public static class SingleRunGuard
{
    private static string LockFilePath => Path.Combine(AppConfig.AppDataFolder, "run.lock");

    public static FileStream? TryAcquire()
    {
        Directory.CreateDirectory(AppConfig.AppDataFolder);

        try
        {
            return new FileStream(LockFilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None);
        }
        catch (IOException)
        {
            return null;
        }
    }
}

Si TryAcquire devuelve null, significa que otra ejecución ya tiene el archivo abierto en exclusiva, así que la nueva se cancela sin más. Usar un FileStream en vez de un Mutex con nombre tiene además otra ventaja, no tiene afinidad de thread, así que puedo adquirirlo en un punto del código y liberarlo (con await using) después de varios await, sin preocuparme de en qué thread del pool termina reanudándose la continuación.

Pedir permisos mínimos a Google

Al configurar el OAuth con Google, pedí deliberadamente el scope más restrictivo posible.

// Scope "appendonly": solo permite crear álbumes y subir/añadir fotos,
// no permite leer ni modificar el resto de tu biblioteca de Google Photos.
private static readonly string[] Scopes = { "https://www.googleapis.com/auth/photoslibrary.appendonly" };

La aplicación nunca lee ni borra nada de la biblioteca. Solo puede crear álbumes nuevos y añadirles fotos. Me parecía importante no pedir más permisos de los estrictamente necesarios, sobre todo tratándose de una app que sube fotos personales.

Programar la ejecución con el planificador nativo de cada sistema operativo

Para no reinventar la rueda con temporizadores propios corriendo en segundo plano, delego la programación en el planificador de cada sistema operativo, seleccionando la implementación correcta en tiempo de ejecución.

public interface IBackgroundScheduler
{
    Task RegisterAsync(IReadOnlyList<ScheduleEntry> entries, string executablePath);
    Task UnregisterAsync();
    Task<bool> IsRegisteredAsync();

    static IBackgroundScheduler Create()
    {
        if (OperatingSystem.IsWindows())
            return new WindowsTaskSchedulerRegistrar();

        if (OperatingSystem.IsMacOS())
            return new MacLaunchdRegistrar();

        throw new PlatformNotSupportedException("Scheduled execution is only implemented for Windows and macOS.");
    }
}

En Windows registro un WeeklyTrigger por cada día/hora configurado usando Task Scheduler, y en macOS escribo un .plist con un StartCalendarInterval por cada entrada y lo cargo con launchctl. Ambos, al llegar la hora, simplemente relanzan la propia aplicación con el argumento --run-scheduled, que activa el modo headless del que hablaba antes. La ventaja de apoyarse en el planificador del sistema en vez de mantener un proceso propio corriendo es que si el ordenador estaba apagado o dormido a la hora programada, tanto Task Scheduler como launchd se encargan de lanzarlo en cuanto vuelve a estar disponible, sin que yo tenga que programar esa lógica.

Tareas pendientes

  • Al no disponer de una cuenta de desarrollador de Apple, la app para macOS no está firmada. Esto hace que, tras descargarla, sea necesario ejecutar el comando xattr -cr DicresPhotosUploader.app antes de poder abrirla, para que Gatekeeper no bloquee su ejecución.

Cierre

Con todo esto, llevo ya un tiempo subiendo mis fotos con la app corriendo sola, programada, sin tener que acordarme de nada. Simplemente guardo mis fotos en mi estructura de carpetas local y éstas se suben a Google Photos.

Esta vez, en vez de dejar un link al repo de la aplicación, os dejo un enlace a la página de Dicres Photos Uploader. En ésta podréis encontrar una descripción funcional de la app, la política de privacidad, los terminos y condiciones y el enlace al repo de GitHub, además del enlace a las releases, desde donde podéis descargar la app.