tiddl

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 16, 2026 License: Apache-2.0 Imports: 16 Imported by: 0

README

go-tiddl

Go Reference License

Go library for talking to the Tidal API: authenticate, fetch track/video metadata, resolve streams, and download audio.

Inspired by oskvr37/tiddl. The examples use the same ~/.tiddl/auth.json format as the Python CLI, so you can share credentials between the two.

Requires Go 1.25+.

Install

go get github.com/binozo/go-tiddl

Usage

Create a client, authenticate, set a country code, then call the API. Most endpoints need CountryCode on the client — GetSession is the usual way to get it after login.

client, err := tiddl.NewClient(
    tiddl.WithLogger(slog.Default()),
    tiddl.WithContext(ctx),
)
if err != nil {
    return err
}
defer client.Close()

// device auth (see below) or load a saved token:
client.SetToken(token)

session, err := client.GetSession(ctx)
if err != nil {
    return err
}
client.CountryCode = session.CountryCode

track, err := client.GetTrack(ctx, 302516870)
Authentication

The library implements Tidal's OAuth2 device code flow:

req, err := client.InitiateDeviceAuth(ctx)
// send the user to req.VerificationUriComplete

result, err := client.WaitForDeviceAuth(ctx, req)
client.SetToken(result.Token)

WaitForDeviceAuth polls until the user approves, the code expires, or the context is cancelled. For token refresh and persistence, use WithAuthToken / SetToken with a refreshable oauth2.Token, and WithTokenChanged to save rotated tokens.

examples/authentication/authentication.go is a complete login flow that reads/writes ~/.tiddl/auth.json. See examples/README.md for how to run it.

Downloading audio
track, _ := client.GetTrack(ctx, trackID)
quality := track.BestQuality()

stream, _ := client.GetTrackStream(ctx, track.ID, quality, false)
reader, _ := client.DownloadTrackStream(ctx, stream)
defer reader.Close()

io.Copy(out, reader)

DownloadTrackStream returns an io.ReadCloser that stitches manifest segments together and prefetches the next segment in the background. Works for both BTS and MPEG-DASH manifests.

Pass immersiveAudio: true to GetTrackStream for Dolby Atmos when the track supports it.

examples/download/download_audio.go downloads a track from a URL or numeric ID to track.flac.

Video
video, _ := client.GetVideo(ctx, videoID)
stream, _ := client.GetVideoStream(ctx, videoID, tiddl.VideoHigh)

VideoStream exposes a parsed manifest with direct segment URLs. There is no DownloadVideoStream helper yet — you'd fetch the URLs yourself.

URL parsing
id, err := tiddl.ParseTrackID("https://tidal.com/track/302516870/u")

Accepts bare numeric IDs and tidal.com/track/... URLs.

Client options

Passed to NewClient:

  • WithAuth — static bearer token (no refresh)
  • WithAuthToken — refreshable oauth2.Token
  • WithTokenChanged — callback on token refresh/rotation
  • WithCountryCode, WithContext, WithLogger, WithHTTPClient, WithBaseURL, WithClient
Audio quality
Constant
Low low bitrate
High 320 kbps AAC
Lossless 16-bit / 44.1 kHz FLAC
HiResLossless up to 24-bit / 192 kHz FLAC

Track.BestQuality() picks the highest quality from the track's mediaMetadata.tags.

Video quality

VideoAudioOnly, VideoLow, VideoMedium, VideoHigh

Errors

Sentinel errors for errors.Is:

  • ErrCountryCodeNotSet
  • ErrTokenExpired, ErrNoTokenProvided, ErrNoOpenAuthSession
  • ErrResourceNotFound
  • ErrDeviceAuthorizationPending, ErrDeviceCodeExpired

Everything else comes back wrapped with context.

What's missing

Search, albums, playlists, artist pages, and video download helpers are not implemented. The streaming/download path for audio is covered by tests; catalog features would build on the existing client.

Disclaimer

Not affiliated with Tidal or Aspiro AB. For personal use only.

License

Apache-2.0 - see LICENSE.

Documentation

Overview

Package tiddl provides a Go client for the Tidal music streaming API.

It supports authentication via the device authorization flow (including a convenient WaitForDeviceAuth helper), track and video metadata retrieval, manifest handling for both BTS and DASH streams, and efficient audio downloads via concurrent segment prefetching.

Index

Examples

Constants

This section is empty.

Variables

View Source
var (
	// ErrTokenExpired indicates the OAuth2 access token has expired.
	ErrTokenExpired = errors.New("token expired")
	// ErrNoTokenProvided indicates no OAuth2 token is available.
	ErrNoTokenProvided = errors.New("no token provided")
	// ErrNoOpenAuthSession indicates there is no active authentication session.
	ErrNoOpenAuthSession = errors.New("no open auth session")
	// ErrResourceNotFound indicates the requested resource does not exist.
	ErrResourceNotFound = errors.New("resource not found")
	// ErrCountryCodeNotSet indicates the client's CountryCode field is empty,
	// which is required for track and video lookups.
	ErrCountryCodeNotSet = errors.New("country code not set")

	// ErrDeviceAuthorizationPending is returned (wrapped) by VerifyDeviceAuth
	// when the user has not yet approved the device authorization request.
	// Callers should poll again after the interval from AuthRequest.
	ErrDeviceAuthorizationPending = errors.New("device authorization pending")

	// ErrDeviceCodeExpired is returned (wrapped) by VerifyDeviceAuth when the
	// device code has expired or is no longer valid.
	ErrDeviceCodeExpired = errors.New("device code expired")
)

Functions

func ParseTrackID

func ParseTrackID(id string) (uint64, error)
Example (Numeric)
package main

import (
	"fmt"

	"github.com/binozo/go-tiddl"
)

func main() {
	id, err := tiddl.ParseTrackID("448702530")
	if err != nil {
		panic(err)
	}
	fmt.Println(id)
}
Output:
448702530
Example (Url)
package main

import (
	"fmt"

	"github.com/binozo/go-tiddl"
)

func main() {
	id, err := tiddl.ParseTrackID("https://tidal.com/track/448702530/u")
	if err != nil {
		panic(err)
	}
	fmt.Println(id)
}
Output:
448702530

Types

type Artist

type Artist struct {
	ID      int     `json:"id"`
	Name    string  `json:"name"`
	Handle  *string `json:"handle"`
	Type    string  `json:"type"`
	Picture string  `json:"picture"`
}

Artist represents a Tidal artist with their basic profile information.

type AudioMode

type AudioMode string

AudioMode represents the audio channel configuration of a stream.

const (
	// Stereo is standard two-channel audio.
	Stereo AudioMode = "STEREO"
	// DolbyAtmos is immersive spatial audio.
	DolbyAtmos AudioMode = "DOLBY_ATMOS"
)

type AudioQuality

type AudioQuality string

AudioQuality represents the audio quality level of a stream.

const (
	// Low is a low-bitrate stream.
	Low AudioQuality = "LOW"
	// High is a high-bitrate AAC stream.
	High AudioQuality = "HIGH"
	// Lossless is a CD-quality FLAC stream (16-bit/44.1 kHz).
	Lossless AudioQuality = "LOSSLESS"
	// HiResLossless is a high-resolution FLAC stream (up to 24-bit/192 kHz).
	HiResLossless AudioQuality = "HI_RES_LOSSLESS"
)

type AuthRequest

type AuthRequest struct {
	DeviceCode              string
	Expires                 time.Time
	Interval                time.Duration
	UserCode                string
	VerificationUri         *url.URL
	VerificationUriComplete *url.URL
}

AuthRequest represents a pending device authorization request returned by InitiateDeviceAuth. The user must visit VerificationUriComplete to approve the request before it expires.

func (*AuthRequest) UnmarshalJSON

func (a *AuthRequest) UnmarshalJSON(data []byte) error

type AuthResult

type AuthResult struct {
	Token      *oauth2.Token
	User       User
	UserID     int
	ClientName string
}

AuthResult represents the result of a successful device authorization, containing the OAuth2 token and user information.

func (*AuthResult) UnmarshalJSON

func (a *AuthResult) UnmarshalJSON(data []byte) error

type BTSManifest

type BTSManifest struct {
	MimeType       string   `json:"mimeType"`
	Codecs         string   `json:"codecs"`
	EncryptionType string   `json:"encryptionType"`
	URLs           []string `json:"urls"`
}

BTSManifest represents a BTS-format stream manifest containing direct URLs.

func (BTSManifest) GetCodecs

func (b BTSManifest) GetCodecs() string

GetCodecs returns the codec identifier for the stream.

func (BTSManifest) GetURLs

func (b BTSManifest) GetURLs() []string

GetURLs returns the download URLs for the stream segments.

type Client

type Client struct {
	CountryCode string
	// contains filtered or unexported fields
}

Client is a Tidal API client. It handles authentication, request building, and token lifecycle management.

func NewClient

func NewClient(opts ...Option) (*Client, error)

NewClient creates a new Tidal API client with the given options. By default, it uses Tidal's public client credentials and the v1 API base URL.

Use WithClient to provide custom OAuth2 credentials, WithAuth or WithAuthToken to provide an existing token, and WithCountryCode to set the country code required for track and video lookups.

func (*Client) Close

func (c *Client) Close() error

func (*Client) DownloadTrackStream

func (c *Client) DownloadTrackStream(ctx context.Context, stream Stream) (io.ReadCloser, error)

DownloadTrackStream returns an io.ReadCloser that streams the audio data from the given Stream's manifest URLs. The reader prefetches segments concurrently for improved throughput. The caller must close the reader when done.

func (*Client) GetSession

func (c *Client) GetSession(ctx context.Context) (Session, error)

GetSession retrieves the current session information from the Tidal API. The returned Session contains the user's country code, which can be used to populate Client.CountryCode for subsequent track and video lookups.

func (*Client) GetTrack

func (c *Client) GetTrack(ctx context.Context, trackID uint64) (Track, error)

GetTrack retrieves metadata for a track by its ID. Requires Client.CountryCode to be set.

func (*Client) GetTrackStream

func (c *Client) GetTrackStream(ctx context.Context, trackID uint64, quality AudioQuality, immersiveAudio bool) (Stream, error)

GetTrackStream retrieves playback information for a track, including the stream manifest needed to download the audio data. The immersiveAudio parameter enables Dolby Atmos when set to true.

func (*Client) GetVideo

func (c *Client) GetVideo(ctx context.Context, videoID uint64) (Video, error)

GetVideo retrieves metadata for a video by its ID. Requires Client.CountryCode to be set.

func (*Client) GetVideoStream

func (c *Client) GetVideoStream(ctx context.Context, videoID uint64, videoQuality VideoQuality) (VideoStream, error)

GetVideoStream retrieves playback information for a video, including the stream manifest needed to download the video data.

func (*Client) InitiateDeviceAuth

func (c *Client) InitiateDeviceAuth(ctx context.Context) (AuthRequest, error)

InitiateDeviceAuth starts the device authorization flow. The returned AuthRequest contains a URL the user must visit to approve the request and a device code used to poll for completion via VerifyDeviceAuth.

func (*Client) RefreshAuth

func (c *Client) RefreshAuth(ctx context.Context, refreshToken string) (AuthResult, error)

RefreshAuth refreshes the authentication using the provided refresh token. On success, the client's token source is automatically updated.

func (*Client) RevokeAuth

func (c *Client) RevokeAuth(ctx context.Context) error

RevokeAuth logs out the current session by calling the Tidal logout endpoint.

func (*Client) SetToken

func (c *Client) SetToken(token *oauth2.Token)

SetToken updates the client's OAuth2 token and reconfigures the HTTP client to use the new token with automatic refresh. Tokens are reused with a 5-minute early expiry margin to proactively refresh before expiration.

func (*Client) SetTokenChanged

func (c *Client) SetTokenChanged(f TokenChangedFunc)

SetTokenChanged registers (or replaces) a callback that will be invoked when the client detects a new token has been obtained.

It is safe to call at any time. The callback will be used for all subsequently created token sources (via SetToken, VerifyDeviceAuth, RefreshAuth, etc.). Callbacks registered this way are best-effort for already-active sources.

func (*Client) Token

func (c *Client) Token() (*oauth2.Token, error)

Token returns the current OAuth2 token from the active token source. Returns ErrNoTokenProvided if no token source is configured.

func (*Client) VerifyDeviceAuth

func (c *Client) VerifyDeviceAuth(ctx context.Context, deviceCode string) (AuthResult, error)

VerifyDeviceAuth completes the device authorization flow by exchanging the device code for access and refresh tokens. On success, the client's token source is automatically updated.

func (*Client) WaitForDeviceAuth

func (c *Client) WaitForDeviceAuth(ctx context.Context, req AuthRequest) (AuthResult, error)

WaitForDeviceAuth repeatedly calls VerifyDeviceAuth (respecting the Interval from the AuthRequest) until the user approves the request on the Tidal side, the code expires, or the context is cancelled.

This is the recommended helper for completing a device flow started by InitiateDeviceAuth.

type DASHManifest

type DASHManifest struct {
	Codecs string
	URLs   []string
}

DASHManifest represents a DASH-format stream manifest with resolved segment URLs.

func (DASHManifest) GetCodecs

func (d DASHManifest) GetCodecs() string

GetCodecs returns the codec identifier for the stream.

func (DASHManifest) GetURLs

func (d DASHManifest) GetURLs() []string

GetURLs returns the download URLs for the stream segments.

type Manifest

type Manifest interface {
	GetURLs() []string
	GetCodecs() string
}

Manifest provides a common interface for accessing stream URLs and codec information from different manifest types.

type MimeType

type MimeType string

MimeType identifies the format of a Tidal stream manifest.

const (
	// MimeTypeBTS is the BTS (binary track stream) manifest format.
	MimeTypeBTS MimeType = "application/vnd.tidal.bts"
	// MimeTypeDASH is the DASH (MPEG-DASH) manifest format.
	MimeTypeDASH MimeType = "application/dash+xml"
)

type Option

type Option func(*config)

Option configures a Client via functional options passed to NewClient.

func WithAuth

func WithAuth(token string) Option

WithAuth configures the client with a static access token that cannot be refreshed. For tokens that support refresh, use WithAuthToken instead.

func WithAuthToken

func WithAuthToken(token *oauth2.Token) Option

WithAuthToken configures the client with an OAuth2 token that supports automatic refresh. The token is reused with a 5-minute early expiry margin.

If a TokenChangedFunc has been registered (via WithTokenChanged), the change detector is placed between the raw refreshing source and the outer reuse (sandwich style) so that refreshed tokens are observed.

func WithBaseURL

func WithBaseURL(baseURL *url.URL) Option

WithBaseURL overrides the Tidal API base URL. Useful for testing or alternative endpoints.

func WithClient

func WithClient(id, secret string) Option

WithClient sets custom OAuth2 client ID and secret instead of the default Tidal public credentials.

func WithContext

func WithContext(ctx context.Context) Option

WithContext sets the parent context for the client. The client derives a cancellable context from it, which is released on Close.

func WithCountryCode

func WithCountryCode(countryCode string) Option

WithCountryCode sets the ISO 3166-1 alpha-2 country code required for track and video API calls (e.g. "US", "DE").

func WithHTTPClient

func WithHTTPClient(httpClient *http.Client) Option

WithHTTPClient sets a custom HTTP client. By default, the client creates one with OAuth2 transport wrapping http.DefaultTransport (no overall timeout, rely on context cancellation for requests).

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger sets the structured logger used by the client for debug output.

func WithTokenChanged

func WithTokenChanged(f TokenChangedFunc) Option

WithTokenChanged registers a callback that will be invoked when the client obtains a new token (typically after an automatic refresh, including refresh token rotation).

The callback is used for all token sources created after this option is applied (construction-time sources, SetToken, VerifyDeviceAuth, etc.). It is the recommended way to persist tokens so that rotated refresh tokens are not lost when the process exits.

type Session

type Session struct {
	SessionID   string `json:"sessionId"`
	UserID      int    `json:"userId"`
	CountryCode string `json:"countryCode"`
	ChannelID   int    `json:"channelId"`
	PartnerID   int    `json:"partnerId"`
	Client      struct {
		ID                       int     `json:"id"`
		Name                     string  `json:"name"`
		AuthorizedForOffline     bool    `json:"authorizedForOffline"`
		AuthorizedForOfflineDate *string `json:"authorizedForOfflineDate"`
	} `json:"client"`
}

Session represents an active Tidal API session.

type Stream

type Stream struct {
	TrackID           int          `json:"trackId"`
	AssetPresentation string       `json:"assetPresentation"`
	AudioMode         AudioMode    `json:"audioMode"`
	AudioQuality      AudioQuality `json:"audioQuality"`
	ManifestMimeType  MimeType     `json:"manifestMimeType"`
	ManifestHash      string       `json:"manifestHash"`
	Manifest          Manifest
	Info              *StreamInfo
}

Stream represents a playable Tidal audio stream, including the manifest needed to download the actual audio data.

func (*Stream) UnmarshalJSON

func (s *Stream) UnmarshalJSON(data []byte) error

type StreamInfo

type StreamInfo struct {
	AlbumReplayGain    float64 `json:"albumReplayGain"`
	AlbumPeakAmplitude float64 `json:"albumPeakAmplitude"`
	TrackReplayGain    float64 `json:"trackReplayGain"`
	TrackPeakAmplitude float64 `json:"trackPeakAmplitude"`
	BitDepth           int     `json:"bitDepth"`
	SampleRate         int     `json:"sampleRate"`
}

StreamInfo contains audio quality metadata such as replay gain, bit depth, and sample rate.

type Tag

type Tag string

Tag represents a quality tag attached to a track's media metadata.

const (
	// TagLossless indicates CD-quality lossless audio.
	TagLossless Tag = "LOSSLESS"
	// TagHiResLossless indicates high-resolution lossless audio.
	TagHiResLossless Tag = "HIRES_LOSSLESS"
)

func (Tag) Quality

func (t Tag) Quality() AudioQuality

Quality maps the tag to its corresponding AudioQuality value.

type TokenChangedFunc

type TokenChangedFunc func(*oauth2.Token)

TokenChangedFunc is called whenever the client obtains a new token, typically because an automatic refresh occurred (including cases where the authorization server rotates the refresh token).

The token passed to the function is a shallow copy. Callers must not mutate it. The callback is invoked synchronously from the token acquisition path (i.e. during HTTP requests that require authorization or explicit calls to Token()) and should return quickly.

type Track

type Track struct {
	ID                     int      `json:"id"`
	Title                  string   `json:"title"`
	Duration               int      `json:"duration"`
	ReplayGain             float64  `json:"replayGain"`
	Peak                   float64  `json:"peak"`
	AllowStreaming         bool     `json:"allowStreaming"`
	StreamReady            bool     `json:"streamReady"`
	PayToStream            bool     `json:"payToStream"`
	AdSupportedStreamReady bool     `json:"adSupportedStreamReady"`
	DjReady                bool     `json:"djReady"`
	StemReady              bool     `json:"stemReady"`
	StreamStartDate        string   `json:"streamStartDate"`
	PremiumStreamingOnly   bool     `json:"premiumStreamingOnly"`
	TrackNumber            int      `json:"trackNumber"`
	VolumeNumber           int      `json:"volumeNumber"`
	Version                *string  `json:"version"`
	Popularity             int      `json:"popularity"`
	Copyright              string   `json:"copyright"`
	Bpm                    int      `json:"bpm"`
	Key                    string   `json:"key"`
	KeyScale               string   `json:"keyScale"`
	URL                    string   `json:"url"`
	Isrc                   string   `json:"isrc"`
	Editable               bool     `json:"editable"`
	Explicit               bool     `json:"explicit"`
	AudioQuality           string   `json:"audioQuality"`
	AudioModes             []string `json:"audioModes"`
	MediaMetadata          struct {
		Tags []Tag `json:"tags"`
	} `json:"mediaMetadata"`
	Upload      bool     `json:"upload"`
	AccessType  string   `json:"accessType"`
	Spotlighted bool     `json:"spotlighted"`
	Ai          bool     `json:"ai"`
	Artist      Artist   `json:"artist"`
	Artists     []Artist `json:"artists"`
	Album       struct {
		ID           int     `json:"id"`
		Title        string  `json:"title"`
		Cover        string  `json:"cover"`
		VibrantColor *string `json:"vibrantColor"`
		VideoCover   any     `json:"videoCover"`
	} `json:"album"`
	Mixes struct {
		TRACKMIX string `json:"TRACK_MIX"`
	} `json:"mixes"`
}

Track represents a Tidal track with its metadata.

func (*Track) BestQuality

func (t *Track) BestQuality() AudioQuality

BestQuality returns the highest available AudioQuality based on the track's media metadata tags. If no tags are present, it defaults to Low.

type User

type User struct {
	UserID             int       `json:"userId"`
	Username           string    `json:"username"`
	AcceptedEULA       bool      `json:"acceptedEULA"`
	AccountLinkCreated bool      `json:"accountLinkCreated"`
	Address            *string   `json:"address"`
	AppleUID           *string   `json:"appleUid"`
	Birthday           time.Time `json:"birthday"`
	ChannelID          int       `json:"channelId"`
	City               *string   `json:"city"`
	CountryCode        string    `json:"countryCode"`
	Created            time.Time `json:"created"`
	Email              string    `json:"email"`
	EmailVerified      bool      `json:"emailVerified"`
	FirstName          *string   `json:"firstName"`
	FullName           *string   `json:"fullName"`
	GoogleUID          *string   `json:"googleUid"`
	LastName           *string   `json:"lastName"`
	NewUser            bool      `json:"newUser"`
	Nickname           *string   `json:"nickname"`
	ParentID           int       `json:"parentId"`
	PhoneNumber        *string   `json:"phoneNumber"`
	PostalCode         *string   `json:"postalcode"`
	Updated            time.Time `json:"updated"`
	USState            *string   `json:"usState"`
}

User represents a Tidal user account.

func (*User) UnmarshalJSON

func (u *User) UnmarshalJSON(data []byte) error

type Video

type Video struct {
	ID                     int           `json:"id"`
	Title                  string        `json:"title"`
	VolumeNumber           int           `json:"volumeNumber"`
	TrackNumber            int           `json:"trackNumber"`
	ReleaseDate            time.Time     `json:"releaseDate"`
	ImagePath              *string       `json:"imagePath"`
	ImageID                string        `json:"imageId"`
	VibrantColor           string        `json:"vibrantColor"`
	Duration               time.Duration `json:"duration"`
	Quality                VideoQuality  `json:"quality"`
	StreamReady            bool          `json:"streamReady"`
	AdSupportedStreamReady bool          `json:"adSupportedStreamReady"`
	DjReady                bool          `json:"djReady"`
	StemReady              bool          `json:"stemReady"`
	StreamStartDate        time.Time     `json:"streamStartDate"`
	AllowStreaming         bool          `json:"allowStreaming"`
	Explicit               bool          `json:"explicit"`
	Popularity             int           `json:"popularity"`
	Type                   string        `json:"type"`
	AdsURL                 *string       `json:"adsUrl"`
	AdsPrePaywallOnly      bool          `json:"adsPrePaywallOnly"`
	Artist                 Artist        `json:"artist"`
	Artists                []Artist      `json:"artists"`
	Album                  *string       `json:"album"`
}

Video represents a Tidal video with its metadata.

func (*Video) UnmarshalJSON

func (v *Video) UnmarshalJSON(data []byte) error

type VideoManifest

type VideoManifest struct {
	MimeType string   `json:"mimeType"`
	URLs     []string `json:"urls"`
}

VideoManifest holds the decoded video stream manifest with download URLs.

type VideoQuality

type VideoQuality string

VideoQuality represents the quality level of a video stream.

const (
	// VideoAudioOnly fetches only the audio track of a video.
	VideoAudioOnly VideoQuality = "AUDIO_ONLY"
	// VideoLow is a low-resolution video stream.
	VideoLow VideoQuality = "LOW"
	// VideoMedium is a medium-resolution video stream.
	VideoMedium VideoQuality = "MEDIUM"
	// VideoHigh is a high-resolution video stream.
	VideoHigh VideoQuality = "HIGH"
)

type VideoStream

type VideoStream struct {
	VideoID           int           `json:"videoId"`
	StreamType        string        `json:"streamType"`
	AssetPresentation string        `json:"assetPresentation"`
	VideoQuality      string        `json:"videoQuality"`
	ManifestMimeType  string        `json:"manifestMimeType"`
	ManifestHash      string        `json:"manifestHash"`
	Manifest          VideoManifest `json:"manifest"`
}

VideoStream represents a playable Tidal video stream with its manifest.

func (*VideoStream) UnmarshalJSON

func (vs *VideoStream) UnmarshalJSON(data []byte) error

Directories

Path Synopsis
examples
authentication command
download command

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL