Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.28.0] - 2026-08-29

### Added
- **Freeleech (FL) token spending on RED/OPS** — opt-in via `RED_USE_FL_TOKEN` /
`OPS_USE_FL_TOKEN` env vars (or `--red-use-token` / `--ops-use-token` CLI flags),
off by default. When enabled, flacfetch spends a Freeleech token on eligible
downloads (`ajax.php?action=download&id=<id>&usetoken=1`) so the download
doesn't count against ratio.
- **Driven by the tracker's `canUseToken` flag** (captured from the `browse`
search response, which the code previously discarded). It's true only when
the account currently holds a spendable token for that torrent (≤ 5 GB, not
already free), so tokens are used automatically whenever available and it
stops once they run out. There is no RED API endpoint that returns a raw
token *count* — `canUseToken` is the authoritative signal.
- Already-free torrents are never charged a token; cached `.torrent` files are
reused without spending one.
- **Graceful fallback:** a failed token spend transparently retries as a normal
(ratio-counted) download, so a token failure never blocks a download.
- Hardened `.torrent` fetch to validate the response is bencoded before caching,
so a JSON error body (e.g. "no tokens left") is never cached as a bogus torrent.

## [0.27.0] - 2026-08-26

### Added
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,31 @@ export OPS_API_URL="your_tracker_url_here"
flacfetch "..." --ops-key "your_key" --ops-url "your_url"
```

**Freeleech Tokens** (Optional)

Gazelle trackers (RED/OPS) let you spend a **Freeleech (FL) token** to download a
torrent without it counting against your ratio. flacfetch can spend tokens
automatically on eligible downloads:

```bash
export RED_USE_FL_TOKEN=true # or: flacfetch "..." --red-use-token
export OPS_USE_FL_TOKEN=true # or: flacfetch "..." --ops-use-token
```

Behaviour (opt-in, **off by default**):
- Spends a token only when the tracker reports one is spendable for that torrent
(its `canUseToken` flag — which is only true when you currently hold a token,
the torrent is ≤ 5 GB, and it isn't already freeleech). This means tokens are
used automatically whenever available and it simply stops once you run out.
- Already-free torrents are never charged a token, and cached `.torrent` files are
reused without spending one.
- If a token spend fails for any reason, flacfetch transparently falls back to a
normal (ratio-counted) download — a failed token never blocks a download.

> Note: there is no RED API endpoint that returns your raw token *count*; the
> per-torrent `canUseToken` flag from search results is the authoritative
> "is a token spendable here" signal, and is what drives this behaviour.

**Spotify Configuration** (Optional - requires Premium account)

Spotify provides CD-quality audio (44.1kHz/16-bit) captured via librespot and converted to FLAC. This uses the official Spotify Web API for authentication (OAuth) and librespot for audio capture.
Expand Down
2 changes: 2 additions & 0 deletions flacfetch/api/routes/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,8 +238,10 @@ async def debug_providers():
"env_vars": {
"RED_API_KEY": bool(os.environ.get("RED_API_KEY")),
"RED_API_URL": bool(os.environ.get("RED_API_URL")),
"RED_USE_FL_TOKEN": os.environ.get("RED_USE_FL_TOKEN", "false").lower() in ("true", "1", "yes"),
"OPS_API_KEY": bool(os.environ.get("OPS_API_KEY")),
"OPS_API_URL": bool(os.environ.get("OPS_API_URL")),
"OPS_USE_FL_TOKEN": os.environ.get("OPS_USE_FL_TOKEN", "false").lower() in ("true", "1", "yes"),
"SPOTIPY_CLIENT_ID": bool(os.environ.get("SPOTIPY_CLIENT_ID")),
"SPOTIPY_CLIENT_SECRET": bool(os.environ.get("SPOTIPY_CLIENT_SECRET")),
},
Expand Down
10 changes: 8 additions & 2 deletions flacfetch/api/services/download_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,12 +224,15 @@ def _get_fetch_manager(self):
# Add RED provider if configured (requires both key and URL)
red_key = os.environ.get("RED_API_KEY")
red_url = os.environ.get("RED_API_URL")
red_use_token = os.environ.get("RED_USE_FL_TOKEN", "false").lower() in ("true", "1", "yes")
if red_key and red_url:
try:
from flacfetch.downloaders.torrent import TorrentDownloader
from flacfetch.providers.red import REDProvider

self._fetch_manager.add_provider(REDProvider(api_key=red_key, base_url=red_url))
self._fetch_manager.add_provider(REDProvider(api_key=red_key, base_url=red_url, use_fl_token=red_use_token))
if red_use_token:
logger.info("RED Freeleech token spending ENABLED (RED_USE_FL_TOKEN)")
self._fetch_manager.register_downloader(
"RED",
TorrentDownloader(keep_seeding=self.keep_seeding)
Expand All @@ -241,12 +244,15 @@ def _get_fetch_manager(self):
# Add OPS provider if configured (requires both key and URL)
ops_key = os.environ.get("OPS_API_KEY")
ops_url = os.environ.get("OPS_API_URL")
ops_use_token = os.environ.get("OPS_USE_FL_TOKEN", "false").lower() in ("true", "1", "yes")
if ops_key and ops_url:
try:
from flacfetch.downloaders.torrent import TorrentDownloader
from flacfetch.providers.ops import OPSProvider

self._fetch_manager.add_provider(OPSProvider(api_key=ops_key, base_url=ops_url))
self._fetch_manager.add_provider(OPSProvider(api_key=ops_key, base_url=ops_url, use_fl_token=ops_use_token))
if ops_use_token:
logger.info("OPS Freeleech token spending ENABLED (OPS_USE_FL_TOKEN)")
self._fetch_manager.register_downloader(
"OPS",
TorrentDownloader(keep_seeding=self.keep_seeding)
Expand Down
8 changes: 8 additions & 0 deletions flacfetch/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,10 @@ class Release:
release_type: Optional[str] = None # e.g. "Album", "Single"
seeders: Optional[int] = None

# Private-tracker freeleech info (RED/OPS)
is_freeleech: bool = False # Torrent is already free (no ratio hit) -- don't spend a token on it
can_use_token: Optional[bool] = None # Server says a Freeleech token can be spent (has tokens, <=5GB, not already free)

# YouTube / Streaming Metadata
channel: Optional[str] = None
view_count: Optional[int] = None
Expand Down Expand Up @@ -153,6 +157,8 @@ def to_dict(self) -> dict:
"catalogue_number": self.catalogue_number,
"release_type": self.release_type,
"seeders": self.seeders,
"is_freeleech": self.is_freeleech,
"can_use_token": self.can_use_token,
"channel": self.channel,
"view_count": self.view_count,
"duration_seconds": self.duration_seconds,
Expand Down Expand Up @@ -214,6 +220,8 @@ def from_dict(cls, data: dict) -> "Release":
catalogue_number=data.get("catalogue_number"),
release_type=data.get("release_type"),
seeders=data.get("seeders"),
is_freeleech=data.get("is_freeleech", False),
can_use_token=data.get("can_use_token"),
channel=data.get("channel"),
view_count=data.get("view_count"),
duration_seconds=data.get("duration_seconds"),
Expand Down
18 changes: 16 additions & 2 deletions flacfetch/interface/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1320,8 +1320,10 @@ def __init__(self, prog, max_help_position=35, width=100):
Environment Variables:
RED_API_KEY API key for RED (lossless FLAC source)
RED_API_URL Base URL for RED API (required if using RED)
RED_USE_FL_TOKEN Spend a RED Freeleech token on eligible downloads (true/false)
OPS_API_KEY API key for OPS (lossless FLAC source)
OPS_API_URL Base URL for OPS API (required if using OPS)
OPS_USE_FL_TOKEN Spend an OPS Freeleech token on eligible downloads (true/false)
SPOTIPY_CLIENT_ID Spotify app client ID
SPOTIPY_CLIENT_SECRET Spotify app client secret
SPOTIPY_REDIRECT_URI OAuth redirect URI (http://127.0.0.1:8888/callback)
Expand Down Expand Up @@ -1418,6 +1420,16 @@ def __init__(self, prog, max_help_position=35, width=100):
metavar="URL",
help="OPS API base URL (or use OPS_API_URL env var)"
)
provider_group.add_argument(
"--red-use-token",
action="store_true",
help="Spend a RED Freeleech token on eligible downloads (or use RED_USE_FL_TOKEN env var)"
)
provider_group.add_argument(
"--ops-use-token",
action="store_true",
help="Spend an OPS Freeleech token on eligible downloads (or use OPS_USE_FL_TOKEN env var)"
)
provider_group.add_argument(
"--no-spotify",
action="store_true",
Expand Down Expand Up @@ -1488,9 +1500,10 @@ def __init__(self, prog, max_help_position=35, width=100):
# Register RED provider
red_key = args.red_key or os.environ.get("RED_API_KEY")
red_url = args.red_url or os.environ.get("RED_API_URL")
red_use_token = args.red_use_token or os.environ.get("RED_USE_FL_TOKEN", "false").lower() in ("true", "1", "yes")
if red_key and red_url:
if artist:
rp = REDProvider(api_key=red_key, base_url=red_url)
rp = REDProvider(api_key=red_key, base_url=red_url, use_fl_token=red_use_token)
rp.search_limit = search_limit
rp.early_termination = use_early_termination
manager.add_provider(rp)
Expand All @@ -1509,9 +1522,10 @@ def __init__(self, prog, max_help_position=35, width=100):
# Register OPS provider
ops_key = args.ops_key or os.environ.get("OPS_API_KEY")
ops_url = args.ops_url or os.environ.get("OPS_API_URL")
ops_use_token = args.ops_use_token or os.environ.get("OPS_USE_FL_TOKEN", "false").lower() in ("true", "1", "yes")
if ops_key and ops_url:
if artist:
ops = OPSProvider(api_key=ops_key, base_url=ops_url)
ops = OPSProvider(api_key=ops_key, base_url=ops_url, use_fl_token=ops_use_token)
ops.search_limit = search_limit
ops.early_termination = use_early_termination
manager.add_provider(ops)
Expand Down
Loading
Loading