Skip to content

Commit c8d4306

Browse files
MagicalTuxclaude
andcommitted
Add godoc comments and improve documentation
- Add comprehensive godoc comments to all public types and functions - Add documentation for key internal functions - Replace custom nullWriter with io.Discard from stdlib - Fix staticcheck warnings (unused err values, redundant return, unused field) - Expand README.md with detailed usage examples, configuration options, API reference, and explanation of how the library works 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 644ad11 commit c8d4306

10 files changed

Lines changed: 293 additions & 29 deletions

File tree

README.md

Lines changed: 204 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -2,27 +2,216 @@
22
[![GoDoc](https://godoc.org/github.com/KarpelesLab/smartremote?status.svg)](https://godoc.org/github.com/KarpelesLab/smartremote)
33
[![Coverage Status](https://coveralls.io/repos/github/KarpelesLab/smartremote/badge.svg?branch=master)](https://coveralls.io/github/KarpelesLab/smartremote?branch=master)
44

5-
65
# SmartRemote
76

8-
NOTE: this is not a remote for your TV, just an easy way to access remote (http) files.
7+
SmartRemote is a Go library that provides seamless access to remote HTTP files with intelligent partial downloading and local caching. Rather than downloading entire files upfront, it allows you to open a URL and read from it like a regular file, automatically fetching only the needed portions on-demand.
8+
9+
This is particularly useful for large files like ISOs, ZIPs, and other archives where you might only need to access specific sections (e.g., reading the central directory of a ZIP file without downloading the entire archive).
10+
11+
## Features
12+
13+
- **Lazy Loading**: Downloads only the blocks you actually read
14+
- **Resume Support**: Partial downloads can be saved and resumed via `.part` files
15+
- **Intelligent Seeking**: Handles seekable HTTP connections using Range requests
16+
- **Concurrent Downloads**: Manages multiple concurrent download clients with configurable limits
17+
- **Idle Background Downloading**: Automatically fills gaps in partial downloads when not actively reading
18+
- **Block-Based Tracking**: Uses efficient RoaringBitmap for tracking downloaded 64KB blocks
19+
- **Standard Interfaces**: Implements `io.Reader`, `io.ReaderAt`, and `io.Seeker`
20+
21+
## Installation
22+
23+
```bash
24+
go get github.com/KarpelesLab/smartremote
25+
```
26+
27+
## Quick Start
28+
29+
```go
30+
package main
31+
32+
import (
33+
"fmt"
34+
"io"
35+
"github.com/KarpelesLab/smartremote"
36+
)
37+
38+
func main() {
39+
// Open a remote file
40+
f, err := smartremote.Open("https://example.com/largefile.zip")
41+
if err != nil {
42+
panic(err)
43+
}
44+
defer f.Close()
45+
46+
// Use f as a regular read-only file
47+
// It will download parts as needed from the remote URL
48+
buf := make([]byte, 1024)
49+
n, err := f.Read(buf)
50+
if err != nil && err != io.EOF {
51+
panic(err)
52+
}
53+
fmt.Printf("Read %d bytes\n", n)
54+
}
55+
```
56+
57+
## How It Works
58+
59+
### Block-Based Downloads
960

10-
How to use:
61+
SmartRemote divides remote files into 64KB blocks. When you read from the file, only the blocks containing the requested data are downloaded. Downloaded blocks are:
1162

12-
```Go
13-
f, err := smartremote.Open("http://...")
14-
if err != nil {
15-
panic(err)
16-
}
63+
1. Stored in a local temporary file
64+
2. Tracked using a RoaringBitmap for efficient status checking
65+
3. Persisted to a `.part` file so downloads can be resumed
1766

18-
// Use "f" as a regular readonly file, it'll download parts as needed from the remote url
67+
### HTTP Range Requests
68+
69+
The library uses HTTP Range requests (status 206 Partial Content) to download specific byte ranges. If the server doesn't support Range requests, SmartRemote falls back to downloading the entire file.
70+
71+
### Connection Pooling
72+
73+
The `DownloadManager` maintains a pool of HTTP connections (default: 10) that are reused across requests. Idle connections are automatically cleaned up after 5 minutes.
74+
75+
### Background Downloading
76+
77+
When there are no active read requests, SmartRemote opportunistically downloads missing blocks in the background, progressively completing the file.
78+
79+
## Advanced Usage
80+
81+
### Custom DownloadManager
82+
83+
Create a custom `DownloadManager` for more control:
84+
85+
```go
86+
dm := smartremote.NewDownloadManager()
87+
dm.MaxConcurrent = 5 // Limit to 5 concurrent connections
88+
dm.MaxDataJump = 1024 * 1024 // Allow skipping up to 1MB when seeking
89+
dm.TmpDir = "/custom/tmp" // Custom temp directory
90+
dm.Client = customHTTPClient // Use a custom http.Client
91+
92+
f, err := dm.Open("https://example.com/file.iso")
93+
if err != nil {
94+
panic(err)
95+
}
96+
defer f.Close()
1997
```
2098

21-
This can be used with any kind of file as long as the server supports resume.
22-
If it doesn't then this will just download the whole file, and still work the
23-
same.
99+
### Specify Local Storage Path
100+
101+
Store the downloaded file at a specific path:
102+
103+
```go
104+
dm := smartremote.NewDownloadManager()
105+
f, err := dm.OpenTo("https://example.com/file.iso", "/path/to/local/file.iso")
106+
if err != nil {
107+
panic(err)
108+
}
109+
defer f.Close()
110+
```
111+
112+
### Simple ReaderAt Interface
113+
114+
For simple use cases where you just need `io.ReaderAt`:
115+
116+
```go
117+
dm := smartremote.NewDownloadManager()
118+
reader := dm.For("https://example.com/file.bin")
119+
120+
buf := make([]byte, 100)
121+
n, err := reader.ReadAt(buf, 1000) // Read 100 bytes starting at offset 1000
122+
```
123+
124+
### Force Complete Download
125+
126+
Download the entire file:
127+
128+
```go
129+
f, err := smartremote.Open("https://example.com/file.zip")
130+
if err != nil {
131+
panic(err)
132+
}
133+
defer f.Close()
134+
135+
// Download everything
136+
err = f.Complete()
137+
if err != nil {
138+
panic(err)
139+
}
140+
```
141+
142+
### Manual Progress Saving
143+
144+
Manually trigger a save of download progress:
145+
146+
```go
147+
f, err := smartremote.Open("https://example.com/file.zip")
148+
if err != nil {
149+
panic(err)
150+
}
151+
152+
// ... perform some reads ...
153+
154+
// Save progress explicitly
155+
err = f.SavePart()
156+
if err != nil {
157+
panic(err)
158+
}
159+
```
160+
161+
## Configuration Options
162+
163+
| Option | Type | Default | Description |
164+
|--------|------|---------|-------------|
165+
| `MaxConcurrent` | `int` | 10 | Maximum number of concurrent HTTP connections |
166+
| `MaxDataJump` | `int64` | 512KB | Maximum bytes to read and discard when seeking forward (vs opening a new connection) |
167+
| `TmpDir` | `string` | `os.TempDir()` | Directory for temporary download files |
168+
| `Client` | `*http.Client` | `http.DefaultClient` | HTTP client for making requests |
169+
| `Logger` | `*log.Logger` | stderr | Logger for debug output |
170+
171+
## API Reference
172+
173+
### Package Functions
174+
175+
- `Open(url string) (*File, error)` - Open a remote URL using the default manager
176+
177+
### DownloadManager
178+
179+
- `NewDownloadManager() *DownloadManager` - Create a new download manager
180+
- `Open(url string) (*File, error)` - Open a URL with auto-generated local path
181+
- `OpenTo(url, localPath string) (*File, error)` - Open a URL with specific local path
182+
- `For(url string) io.ReaderAt` - Get a simple ReaderAt for a URL
183+
184+
### File
185+
186+
- `Read(p []byte) (n int, err error)` - Read from current position
187+
- `ReadAt(p []byte, off int64) (int, error)` - Read from specific offset
188+
- `Seek(offset int64, whence int) (int64, error)` - Seek to position
189+
- `Close() error` - Close file and save progress
190+
- `GetSize() (int64, error)` - Get remote file size
191+
- `SetSize(size int64)` - Manually set file size
192+
- `Stat() (os.FileInfo, error)` - Get file info
193+
- `Complete() error` - Download entire file
194+
- `SavePart() error` - Manually save download progress
195+
196+
## Resume Behavior
197+
198+
When opening a URL:
199+
200+
1. If the local file doesn't exist, a new download begins
201+
2. If the local file exists with a `.part` file, the download resumes from where it left off
202+
3. If the local file exists without a `.part` file, it's assumed to be complete
203+
204+
On close:
205+
- If download is incomplete and progress was saved successfully, both files are kept for resume
206+
- If download is incomplete and progress save failed, the partial file is deleted
207+
- If download is complete, the `.part` file is removed
208+
209+
## Requirements
210+
211+
- Go 1.18 or later
212+
- Server must support HTTP Range requests for partial downloads (falls back to full download otherwise)
24213

25-
# TODO
214+
## TODO
26215

27-
* Add support for range invalidation (bad checksum causes re-download of affected area)
28-
* Refactor idle downloader for better performances
216+
- Add support for range invalidation (bad checksum causes re-download of affected area)
217+
- Refactor idle downloader for better performance

client.go

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import (
99
"time"
1010
)
1111

12+
// dlClient is an internal HTTP connection handler that manages individual
13+
// connections to remote URLs. It handles Range requests, connection reuse,
14+
// and idle background downloading of missing blocks.
1215
type dlClient struct {
1316
dlm *DownloadManager
1417
url string
@@ -23,6 +26,7 @@ type dlClient struct {
2326
expire time.Time
2427
}
2528

29+
// Close closes the HTTP connection and signals completion to the manager.
2630
func (dl *dlClient) Close() error {
2731
dl.lk.Lock()
2832
defer dl.lk.Unlock()
@@ -38,17 +42,19 @@ func (dl *dlClient) Close() error {
3842
return nil
3943
}
4044

45+
// dropDataCount reads and discards cnt bytes from the HTTP response body.
46+
// If a handler is set, it opportunistically saves complete blocks to disk.
4147
func (dl *dlClient) dropDataCount(cnt, startPos int64) error {
4248
if dl.handler == nil {
43-
_, err := io.CopyN(nullWriter{}, dl.reader.Body, cnt)
49+
_, err := io.CopyN(io.Discard, dl.reader.Body, cnt)
4450
return err
4551
}
4652

4753
// download data in buffers
4854
sz := dl.handler.getBlockSize()
4955
if sz <= 0 || cnt < sz {
5056
// doesn't want data?
51-
_, err := io.CopyN(nullWriter{}, dl.reader.Body, cnt)
57+
_, err := io.CopyN(io.Discard, dl.reader.Body, cnt)
5258
return err
5359
}
5460

@@ -57,7 +63,7 @@ func (dl *dlClient) dropDataCount(cnt, startPos int64) error {
5763
for cnt > 0 {
5864
if cnt < sz {
5965
// can't download enough so that it's worth it
60-
_, err := io.CopyN(nullWriter{}, dl.reader.Body, cnt)
66+
_, err := io.CopyN(io.Discard, dl.reader.Body, cnt)
6167
return err
6268
}
6369

@@ -71,7 +77,7 @@ func (dl *dlClient) dropDataCount(cnt, startPos int64) error {
7177
err = dl.handler.ingestData(buf, startPos)
7278
if err != nil {
7379
// give up
74-
_, err := io.CopyN(nullWriter{}, dl.reader.Body, cnt)
80+
_, err := io.CopyN(io.Discard, dl.reader.Body, cnt)
7581
return err
7682
}
7783

@@ -81,6 +87,8 @@ func (dl *dlClient) dropDataCount(cnt, startPos int64) error {
8187
return nil
8288
}
8389

90+
// ReadAt reads data from the remote URL at the specified offset into p.
91+
// It reuses existing HTTP connections when possible, or opens new ones as needed.
8492
func (dl *dlClient) ReadAt(p []byte, off int64) (int, error) {
8593
dl.lk.Lock()
8694
defer dl.lk.Unlock()
@@ -110,10 +118,13 @@ func (dl *dlClient) ReadAt(p []byte, off int64) (int, error) {
110118
}
111119
}
112120

113-
// instanciate a new reader if needed
121+
// instantiate a new reader if needed
114122
if dl.reader == nil {
115123
// spawn a new reader
116124
req, err := http.NewRequest("GET", dl.url, nil)
125+
if err != nil {
126+
return 0, err
127+
}
117128

118129
if off != 0 {
119130
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", off))
@@ -147,6 +158,8 @@ func (dl *dlClient) ReadAt(p []byte, off int64) (int, error) {
147158
return n, err
148159
}
149160

161+
// idleTaskRun is called during idle periods to download missing blocks
162+
// in the background. It runs in a separate goroutine.
150163
func (dl *dlClient) idleTaskRun() {
151164
// this is run in a separate process
152165

@@ -207,6 +220,10 @@ func (dl *dlClient) idleTaskRun() {
207220

208221
// spawn a new reader
209222
req, err := http.NewRequest("GET", dl.url, nil)
223+
if err != nil {
224+
dl.dlm.logf("idle: failed to create request: %s", err)
225+
return
226+
}
210227

211228
if off != 0 {
212229
req.Header.Set("Range", fmt.Sprintf("bytes=%d-", off))
@@ -250,5 +267,4 @@ func (dl *dlClient) idleTaskRun() {
250267
dl.dlm.logf("idle write failed: %s", err)
251268
dl.failure = true
252269
}
253-
return
254270
}

download.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import (
66
"io"
77
)
88

9+
// downloadFull downloads the entire file when partial downloads are not
10+
// supported by the server. This is a fallback for servers that don't
11+
// support HTTP Range requests.
912
func (f *File) downloadFull() error {
1013
// download the file fully
1114
// (we are in a lock)
@@ -45,6 +48,9 @@ func (f *File) downloadFull() error {
4548
return nil
4649
}
4750

51+
// needBlocks ensures that all blocks from start to end (inclusive) are
52+
// downloaded and available locally. It skips blocks that are already
53+
// downloaded and saves progress after downloading.
4854
func (f *File) needBlocks(start, end uint32) error {
4955
// ensure listed blocks exist and are downloaded
5056
// need to be called with lock acquired

factory.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import (
1010
"github.com/RoaringBitmap/roaring"
1111
)
1212

13+
// DefaultBlockSize is the size in bytes of each download block (64KB).
14+
// Downloaded data is tracked and stored in blocks of this size.
1315
const DefaultBlockSize = 65536
1416

1517
// Open a given URL and return a file pointer that will run partial downloads
@@ -42,6 +44,9 @@ func (dlm *DownloadManager) Open(u string) (*File, error) {
4244
return dlm.OpenTo(u, localPath)
4345
}
4446

47+
// OpenTo opens a given URL and stores downloaded data at the specified local
48+
// path. If the file already exists with a .part file, the download will resume.
49+
// If the file exists without a .part file, it is assumed to be complete.
4550
func (dlm *DownloadManager) OpenTo(u, localPath string) (*File, error) {
4651
// generate hash (again if called with Open)
4752
hash := sha256.Sum256([]byte(u))

file.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,17 @@ import (
77
"github.com/RoaringBitmap/roaring"
88
)
99

10+
// File represents a remote file that can be accessed locally through partial
11+
// downloads. It implements io.Reader, io.ReaderAt, and io.Seeker interfaces,
12+
// allowing transparent access to remote HTTP content as if it were a local file.
13+
// Downloaded data is cached locally in blocks, and only the required portions
14+
// are fetched on demand. Partial download progress can be persisted to disk
15+
// and resumed later.
1016
type File struct {
1117
path string // local path on disk
1218
url string // url
1319
hash [32]byte
1420

15-
offset int64 // offset in url
1621
size int64 // size of url
1722
hasSize bool // is size valid
1823
pos int64 // read position in file

0 commit comments

Comments
 (0)