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.
-
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. -
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> -
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_sizewell 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
- Fork it (https://github.com/sdogruyol/yandex-disk/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
Contributors
- Serdar Dogruyol — creator and maintainer
yandex-disk-cr
- 0
- 0
- 0
- 0
- 0
- about 7 hours ago
- August 10, 2026
MIT License
Mon, 10 Aug 2026 15:34:52 GMT