yandex-disk-cr

yandex-disk

A Yandex.Disk SDK for Crystal.

YandexDisk::Client wraps the REST API — metadata and listings, directories, copy/move/delete, upload and download, publishing, custom properties, the Trash, and the background operations the API starts for work it cannot finish inline. Transient failures are retried for you, and one client is safe to share across fibers.

On top of that the shard ships two optional layers you can ignore if you only want the API: Uploader, for chunked resumable transfers of large files, and Syncer plus the yandex-disk-sync binary, for mirroring local directories onto the Disk.

require "yandex-disk"

client = YandexDisk::Client.new(ENV["YANDEX_DISK_TOKEN"])

client.mkdir_p("disk:/reports/2026")
client.upload("chart.png", "disk:/reports/2026/chart.png")
puts client.publish("disk:/reports/2026/chart.png").public_url

Install

dependencies:
  yandex-disk:
    github: sdogruyol/yandex-disk
$ shards install

Authentication

The API authenticates with an OAuth token.

  1. Register an application at https://oauth.yandex.com/client/new with the Yandex.Disk REST API permissions you need — cloud_api:disk.info, cloud_api:disk.read, cloud_api:disk.write.

  2. Send yourself through the implicit-grant flow and copy the token out of the URL fragment:

    https://oauth.yandex.com/authorize?response_type=token&client_id=<YOUR_CLIENT_ID>
    
  3. Hand it to the client:

    client = YandexDisk::Client.new(ENV["YANDEX_DISK_TOKEN"])
    

An empty token is rejected at construction rather than on the first call, and a token the API rejects raises YandexDisk::AuthError, which is never retried.

Paths

The API accepts /photo.png and answers with disk:/photo.png. Every method here normalises its arguments, so both forms work and the two never have to be compared by hand. Trash lives in its own namespace — a deleted disk:/a/b.txt becomes trash:/b.txt — and the trash_* methods put paths there for you.

YandexDisk::RemotePath.normalize("//a//b/")  # => "disk:/a/b"
YandexDisk::RemotePath.trash("/a/b.txt")     # => "trash:/a/b.txt"
YandexDisk::RemotePath.dirname("disk:/a/b")  # => "disk:/a"
YandexDisk::RemotePath.ancestors("/a/b/c")   # => ["disk:/a", "disk:/a/b", "disk:/a/b/c"]

API

Disk

info = client.disk_info
info.total_space          # => 10737418240
info.used_space           # => 4294967296
info.free_space           # => 6442450944
info.trash_size           # => 0
info.system_folders       # => {"downloads" => "disk:/Downloads", ...}

Browsing

resource = client.metadata("disk:/reports/2026/chart.png")
resource.size             # => 20480
resource.md5              # => "d41d8cd98f00b204e9800998ecf8427e"
resource.modified         # => 2026-08-10 09:14:00.0 UTC
resource.file?            # => true

client.find("disk:/nope")     # => nil rather than raising
client.exists?("disk:/a")     # => false

# Pages through the whole directory, not just the first 20 entries.
client.list("disk:/reports/2026").each { |r| puts "#{r.name}\t#{r.size}" }

# Trim the response to the keys you read.
client.list("disk:/big", fields: "_embedded.items.name,_embedded.items.size")

# Ignore the tree entirely.
client.files(limit: 100, media_type: "image")
client.last_uploaded(limit: 10)

Directories

client.mkdir("disk:/reports")             # => false if it already existed
client.mkdir_p("disk:/reports/2026/q3")   # creates every missing level

Uploading

Client#upload is the everyday entry point. Files above chunk_size (16 MB by default) go up as Content-Range chunks, so a connection that drops at 9 GB of a 10 GB file costs the chunk in flight rather than the whole transfer.

client.upload("dump.sql.gz", "disk:/backups/dump.sql.gz")

client.upload("movie.mkv", "disk:/media/movie.mkv", chunk_size: 64_i64 * 1024 * 1024) do |sent, total|
  print "\r#{sent * 100 // total}%"
end

Reuse one Uploader when pushing many files with the same settings:

uploader = YandexDisk::Uploader.new(client, chunk_size: 32_i64 * 1024 * 1024)
files.each { |f| uploader.upload(f, "disk:/inbox/#{File.basename(f)}") }

Have Yandex fetch a URL directly, without the bytes passing through you:

handle = client.upload_from_url("https://example.com/release.zip", "disk:/inbox/release.zip")
client.wait(handle)

Driving the transfer yourself:

link = client.upload_link("disk:/a.bin", overwrite: true)  # valid for 30 minutes
client.put_upload(link.href, io, content_length: 1024_i64)

Downloading

client.download_to("disk:/backups/dump.sql.gz", "restore.sql.gz")  # => bytes written
client.download_link("disk:/a.bin").href                            # pre-authorised URL

download_to reopens and truncates the local file on every attempt, so a retry after a half-finished transfer starts clean instead of appending to a stump.

Copy, move, delete

Non-empty folders are handled in the background, so these return an OperationHandle?nil means the API already finished the job.

client.copy("disk:/a/photo.png", "disk:/b/photo.png")          # => nil
client.move("disk:/notes.md", "disk:/archive/notes.md")

if handle = client.copy("disk:/big-tree", "disk:/backup-tree")
  client.wait(handle)                                          # blocks until it settles
end

client.delete("disk:/old.log")                    # permanent
client.delete("disk:/old.log", permanently: false) # to the Trash

wait raises OperationFailedError when the API reports failure and OperationTimeoutError when the operation is still running at the deadline — a still-running operation is not a finished one, and quietly returning "in-progress" would invite exactly that mistake. Poll by hand with client.operation(id).

Publishing

resource = client.publish("disk:/reports/2026/chart.png")
resource.public_url     # => "https://yadi.sk/i/..."
resource.public_key     # => "..."
resource.published?     # => true

client.published        # everything this account has published
client.unpublish("disk:/reports/2026/chart.png")

Reading and saving someone else's published resource, by key or by yadi.sk URL:

shared = client.public_metadata("https://yadi.sk/d/abc123")
shared.items.each { |entry| puts entry.name }   # for a published folder

client.download_public_to("https://yadi.sk/d/abc123", "shared.zip")
client.save_public_to_disk("https://yadi.sk/d/abc123", name: "copy.zip")

Custom properties

Arbitrary key/value metadata that rides along with a resource. Values merge with what is already there; nil deletes a key. The API caps the whole object at 1 KB.

client.set_custom_properties("disk:/backups/dump.sql.gz", {
  "source"      => JSON::Any.new("db-01"),
  "schema"      => JSON::Any.new(42_i64),
  "provisional" => nil,
})

client.metadata("disk:/backups/dump.sql.gz").custom_properties
# => {"source" => "db-01", "schema" => 42}

Trash

client.trash_list.each { |r| puts "#{r.name} (was #{r.origin_path})" }

client.trash_restore("/dump.sql.gz")
client.trash_restore("/dump.sql.gz", name: "recovered.sql.gz", overwrite: true)

client.trash_delete("/dump.sql.gz")
client.empty_trash

Errors

Every failure is a YandexDisk::Error, and each subclass knows whether retrying it could possibly help.

Class Raised on Retried
AuthError 401, 403 no
NotFoundError 404 no
ConflictError 409 no
FileTooLargeError 413 no
RateLimitError 429 yes, honouring Retry-After
InsufficientStorageError 507 no
APIError any other non-success status 5xx only
TransportError dropped sockets, timeouts yes
ConfigError bad token or config no
UploadError upload link kept expiring no
OperationFailedError / OperationTimeoutError background operation no

APIError carries the HTTP status, plus the code, description and api_message Yandex sends in the body. The exception message leads with description, because Yandex writes that one in English while message comes back in the account's language regardless of Accept-Language:

rescue ex : YandexDisk::APIError
  ex.message      # => "HTTP 404: Resource not found. (DiskNotFoundError)"
  ex.status       # => 404
  ex.code         # => "DiskNotFoundError"
  ex.api_message  # => "Не удалось найти запрошенный ресурс."
end

Retries use exponential backoff with jitter. Tune it, or turn it off:

client = YandexDisk::Client.new(
  token,
  YandexDisk::RetryPolicy.new(
    max_attempts: 8,
    base_delay: 500.milliseconds,
    max_delay: 2.minutes,
    jitter: 0.25,
  )
)

no_retries = YandexDisk::RetryPolicy.new(max_attempts: 1)

Concurrency

One Client can be shared by any number of fibers: each request checks a connection out of a per-host keep-alive pool for its duration, and connections that raised are discarded rather than reused. Call #close when you are done to release them.

client = YandexDisk::Client.new(token)

begin
  channel = Channel(Nil).new
  files.each do |file|
    spawn do
      client.upload(file, "disk:/inbox/#{File.basename(file)}")
      channel.send(nil)
    end
  end
  files.size.times { channel.receive }
ensure
  client.close
end

Directory sync

An optional layer for mirroring local directories onto the Disk: it scans, skips what is already there byte-for-byte, uploads several files at once, and prunes old copies.

config = YandexDisk::Config.from_file("sync.yml")
client = YandexDisk::Client.new(config.resolve_token, config.retry.to_policy)

report = YandexDisk::Syncer.new(config, client).run
puts report.summary   # "3 uploaded, 11 unchanged, 2 pruned — 4.1 GiB in 2m17s"
exit 1 unless report.success?
token_file: /etc/yandex-disk/token   # or token:, or token_env:
remote_root: disk:/backups

concurrency: 4        # files uploaded at once
chunk_size: 16MB      # files above this go up in resumable chunks

skip_unchanged: true  # do not re-upload an identical remote copy
verify_checksum: true # compare MD5, not just size
overwrite: true

retry:
  max_attempts: 5
  base_delay: 1s
  max_delay: 60s
  jitter: 0.25

retention:
  keep_last: 14
  keep_days: 30

sources:
  - path: /var/backups/postgres
    patterns: ["*.sql.gz"]
    exclude: ["*.part", "*.tmp"]
    remote_subdir: postgres     # -> disk:/backups/postgres

  - path: /var/backups/files
    patterns: ["*.tar.zst"]
    recursive: true             # mirrors the local tree remotely
    retention:
      keep_last: 7              # overrides the global policy

How retention decides

A file is kept when it satisfies any configured rule, and deleted only when it satisfies none. keep_last: 14 plus keep_days: 30 therefore means "the 14 newest, plus everything from the last 30 days" — the forgiving reading, on the grounds that the failure mode of the strict one is deleting a file someone still needed.

Omit the retention: block and nothing is ever deleted. Retention applies per remote directory and never touches directories themselves.

Confirm a new policy with a dry run first. The dry run folds in the uploads it would have made, so the preview reflects the Disk as it would be after a real run.

The yandex-disk-sync CLI

The shard also builds a standalone binary around the sync engine, meant for cron.

$ shards build --release
$ sudo install -m 0755 bin/yandex-disk-sync /usr/local/bin/
$ yandex-disk-sync init /etc/yandex-disk/sync.yml
$ yandex-disk-sync sync -c /etc/yandex-disk/sync.yml
$ yandex-disk-sync sync -c /etc/yandex-disk/sync.yml --dry-run
$ yandex-disk-sync sync -c /etc/yandex-disk/sync.yml -q      # cron: warnings and up

$ yandex-disk-sync info -c /etc/yandex-disk/sync.yml
$ yandex-disk-sync ls   disk:/backups/postgres
$ yandex-disk-sync put  dump.sql.gz disk:/backups/manual/dump.sql.gz
$ yandex-disk-sync get  disk:/backups/postgres/latest.sql.gz ./restore.sql.gz
$ yandex-disk-sync rm   disk:/backups/postgres/old.sql.gz

Exit codes are meant to be read by monitoring: 0 clean, 1 finished with failures, 2 never started. The one-off commands fall back to $YANDEX_DISK_TOKEN when -c is not given.

cron and systemd
30 3 * * * root /usr/local/bin/yandex-disk-sync sync -c /etc/yandex-disk/sync.yml -q
# /etc/systemd/system/yandex-disk-sync.service
[Unit]
Description=Sync directories to Yandex.Disk
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/bin/yandex-disk-sync sync -c /etc/yandex-disk/sync.yml
# /etc/systemd/system/yandex-disk-sync.timer
[Unit]
Description=Nightly Yandex.Disk sync

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target

Known limits

  • Per-file size is capped by Yandex at 1 GB, or 50 GB with a Yandex 360 subscription. A larger file comes back as FileTooLargeError.
  • Upload resume is per-run. Upload links expire after 30 minutes and the upload host keeps no state across them, so a link that goes stale mid-file restarts that file from byte 0 (at most three times, then it gives up). Keeping chunk_size well inside the 30-minute window is what stops that from happening on a slow link.
  • MD5 is what the API exposes, so that is what the sync layer's skip check uses. It is a change detector here, not a security control.
  • Custom properties are limited by the API to 1 KB of flat name/value pairs — no arrays, no nesting.

Development

$ crystal spec           # 135 examples, no network required
$ crystal tool format src spec scripts
$ shards build --release

Client, Uploader and Syncer are tested against a real loopback HTTP server backed by an in-memory fake Disk, so routing, pagination, Content-Range chunking, retries, background operations and retention are exercised end to end without a token.

Live smoke test

Some things only a real account can settle: whether Yandex's upload host accepts our Content-Range sequence, whether MD5s survive the round trip, and whether the API answers the way the docs say. scripts/smoke.cr checks exactly that.

$ export YANDEX_DISK_TOKEN=y0_...
$ crystal run scripts/smoke.cr

It creates one throwaway folder named after the run, exercises the SDK inside it, and deletes it at the end. Nothing outside that folder is modified, the Trash is never emptied, and a bad token stops the run before anything is created. Exit code is 0 only if every check passed.

$ crystal run scripts/smoke.cr -- --size=200MiB --chunk=8MiB   # exercise real chunking
$ crystal run scripts/smoke.cr -- --root=disk:/scratch -v      # elsewhere, with logs
$ crystal run scripts/smoke.cr -- --with-downloads             # also writes to Downloads

Contributing

  1. Fork it (https://github.com/sdogruyol/yandex-disk/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

Contributors

Repository

yandex-disk-cr

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 0
  • about 7 hours ago
  • August 10, 2026
License

MIT License

Links
Synced at

Mon, 10 Aug 2026 15:34:52 GMT

Languages