← All projects

Exhibition

A desktop game launcher that treats two acquisition paths as interchangeable: seed it over BitTorrent, or hand the magnet to Real-Debrid and let someone else's line take the download. I built it to learn Go and experiment with interchangeable download backends.

Open source Go Wails v3 rain (BitTorrent) Real-Debrid

The problem

Every game launcher does the same job: find a title, hand it to a download backend, track progress, launch when it's done. What they don't do is treat backends as swappable. Exhibition takes either a magnet link for a local torrent session or the same magnet for Real-Debrid's unrestricted service, and the rest of the app doesn't care which was used.

Why these choices

Normalising before matching

Titles arrive as "Some Game (v1.0.0) [Multi]" from providers and as "some game" from the user. Fuzzy search fails on punctuation and case mismatches, so both sides get the same normalisation pass before comparison.

gosearch/fuzzy.go
func (fuzzyManager *FuzzyManager) IndexFuzzy(games map[int]json_models.Game) {
    fuzzyManager.GamesMap = make(map[string]int)
    for id, game := range games {
        name := strings.ToLower(game.Name)
        // Remove weird characters
        name = strings.Map(func(r rune) rune {
            if strings.ContainsRune(",!?.$#@%^&*()-+=[]/';:<>", r) {
                return -1
            }
            return r
        }, name)
        fuzzyManager.GamesMap[name] = id
    }
}

Returning -1 from a strings.Map callback deletes the rune. That's the idiomatic Go filter, and it's why no second pass is needed.

Two downloads, one queue

The queue stores a UUID and a type discriminator. Adding an item means pushing a tagged struct onto a slice; the runner type-switches on it later. Adding a third backend doesn't touch the queue at all.

goexhibition_queue/realdebrid.go
func (q *Queue) AddRealDebridDownloadToQueue(magnetLink string) {
    d := Download{
        UUID:       uuid.New().String(),
        Type:       RealDebridType,
        MagnetLink: magnetLink,
    }
    q.AddDownloadToQueue(d)
}

Real-Debrid's 503 means "not cached"

/unrestrict/check answers 503 when a host has no cached copy ready. That's not an error condition, it's a routing decision: the link goes to the torrent backend instead. Treating it as a failure would send every uncached download down a dead end.

gotorrent/realdebrid/unrestrict.go
func (client *RealDebridClient) UnrestrictCheck(link string) (bool, error) {
    params := url.Values{}
    params.Add("link", link)

    req, err := client.newRequest(http.MethodPost, "/unrestrict/check", nil, params)
    if err != nil {
        return false, fmt.Errorf("could not post link: %w", err)
    }

    resp, err := client.client.Do(req)
    if resp.StatusCode == 503 {
        return false, nil
    }

    return true, nil
}

BitTorrent settings are just struct fields

rain exposes DHT, PEX, and the port range directly, so making them user settings took copying them onto the config. ResumeOnStartup is deliberately off: the launcher owns the session lifecycle, and a half-finished torrent resuming behind the user's back is a bug generator.

gotorrent/torrent.go
conf := torrent.DefaultConfig
conf.DataDir = path
conf.DataDirIncludesTorrentID = false
conf.Debug = false

conf.DHTEnabled = bittorrentSettings.UseDHT
conf.PEXEnabled = bittorrentSettings.UsePEX

conf.ResumeOnStartup = false
conf.Database = filepath.Join(path, "bittorrent.db")

conf.PortBegin = bittorrentSettings.StartPort
conf.PortEnd = bittorrentSettings.EndPort

session, err := torrent.NewSession(conf)

Current limitations