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
79 changes: 79 additions & 0 deletions backend/src/alembic/versions/e9a3b6c4d2f7_add_giveaway_is_dlc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""add is_dlc flag and dlc_priority_enabled setting

DLC listings on SteamGifts are for games the user owns, so DLC giveaways get
the same autojoin priority treatment as wishlist ones. The is_dlc flag records
which giveaways came from a DLC scan (now part of every scan, like wishlist);
dlc_priority_enabled replaces dlc_enabled and only controls the autojoin
priority/bypass, not the scanning.

Revision ID: e9a3b6c4d2f7
Revises: d4e7f2a8b1c5
Create Date: 2026-07-19 10:00:00.000000

"""
from collections.abc import Sequence

import sqlalchemy as sa

from alembic import op

# revision identifiers, used by Alembic.
revision: str = 'e9a3b6c4d2f7'
down_revision: str | Sequence[str] | None = 'd4e7f2a8b1c5'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
"""Upgrade schema."""
with op.batch_alter_table('giveaways', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'is_dlc',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
comment='DLC giveaway (DLC listings are for games the user owns)',
)
)

with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'dlc_priority_enabled',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
comment='DLC giveaways bypass autojoin filters and are entered '
'after wishlist ones',
)
)

# Carry the old opt-in over: users who entered DLC giveaways before keep
# entering them (now with priority) instead of being silently reset.
op.execute('UPDATE settings SET dlc_priority_enabled = dlc_enabled')

with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.drop_column('dlc_enabled')


def downgrade() -> None:
"""Downgrade schema."""
with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.add_column(
sa.Column(
'dlc_enabled',
sa.Boolean(),
nullable=False,
server_default=sa.false(),
comment='Whether to enter DLC giveaways',
)
)

op.execute('UPDATE settings SET dlc_enabled = dlc_priority_enabled')

with op.batch_alter_table('settings', schema=None) as batch_op:
batch_op.drop_column('dlc_priority_enabled')

with op.batch_alter_table('giveaways', schema=None) as batch_op:
batch_op.drop_column('is_dlc')
5 changes: 5 additions & 0 deletions backend/src/api/schemas/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ class GiveawayBase(BaseModel):
description="Whether game is on user's Steam wishlist",
examples=[False],
)
is_dlc: bool = Field(
default=False,
description="DLC giveaway (DLC listings are for games the user owns)",
examples=[False],
)
is_won: bool = Field(
default=False,
description="Whether user has won this giveaway",
Expand Down
10 changes: 5 additions & 5 deletions backend/src/api/schemas/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ class SettingsBase(BaseModel):
)

# DLC Settings
dlc_enabled: bool = Field(
dlc_priority_enabled: bool = Field(
default=False,
description="Whether to enter DLC giveaways",
description="DLC giveaways bypass autojoin filters and are entered after wishlist ones",
examples=[False],
)

Expand Down Expand Up @@ -203,7 +203,7 @@ class SettingsResponse(SettingsBase):
"phpsessid": "abc123...",
"user_agent": "Mozilla/5.0 (X11; Linux x86_64) Firefox/82.0",
"xsrf_token": None,
"dlc_enabled": False,
"dlc_priority_enabled": False,
"autojoin_enabled": True,
"autojoin_start_at": 350,
"autojoin_stop_at": 200,
Expand Down Expand Up @@ -253,9 +253,9 @@ class SettingsUpdate(BaseModel):
)

# DLC Settings
dlc_enabled: bool | None = Field(
dlc_priority_enabled: bool | None = Field(
default=None,
description="Whether to enter DLC giveaways",
description="DLC giveaways bypass autojoin filters and are entered after wishlist ones",
)

# Safety Settings
Expand Down
5 changes: 5 additions & 0 deletions backend/src/models/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,11 @@ class Giveaway(Base, TimestampMixin):
default=False,
comment="Game is on user's Steam wishlist",
)
is_dlc: Mapped[bool] = mapped_column(
Boolean,
default=False,
comment="DLC giveaway (DLC listings are for games the user owns)",
)
is_won: Mapped[bool] = mapped_column(
Boolean,
default=False,
Expand Down
7 changes: 4 additions & 3 deletions backend/src/models/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ class Settings(Base, TimestampMixin):
xsrf_token: Anti-CSRF token from SteamGifts (extracted from pages)

DLC Settings:
dlc_enabled: Whether to enter DLC giveaways (default: False)
dlc_priority_enabled: DLC giveaways bypass autojoin filters and are
entered after wishlist ones (default: False)

Auto-join Settings:
autojoin_enabled: Enable automatic giveaway entry (default: False)
Expand Down Expand Up @@ -89,10 +90,10 @@ class Settings(Base, TimestampMixin):
# NOTE: current_points is fetched dynamically from SteamGifts, not stored here

# ==================== DLC Settings ====================
dlc_enabled: Mapped[bool] = mapped_column(
dlc_priority_enabled: Mapped[bool] = mapped_column(
Boolean,
default=False,
comment="Whether to enter DLC giveaways",
comment="DLC giveaways bypass autojoin filters and are entered after wishlist ones",
)

# ==================== Safety Settings ====================
Expand Down
51 changes: 33 additions & 18 deletions backend/src/repositories/giveaway.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from datetime import datetime, timedelta
from typing import Any

from sqlalchemy import and_, or_, select, update
from sqlalchemy import ColumnElement, and_, or_, select, update
from sqlalchemy.ext.asyncio import AsyncSession

from core.time import utcnow
Expand Down Expand Up @@ -205,6 +205,7 @@ async def get_eligible(
max_game_age: int | None = None,
limit: int | None = None,
wishlist_priority: bool = True,
dlc_priority: bool = False,
) -> list[Giveaway]:
"""
Get eligible giveaways based on autojoin criteria.
Expand All @@ -219,7 +220,8 @@ async def get_eligible(
When ``wishlist_priority`` is on (default), wishlist giveaways bypass
the price and game-quality filters (the user explicitly wants those
games) and sort before everything else. When off, they pass the same
filters as everything else.
filters as everything else. ``dlc_priority`` grants DLC giveaways the
same bypass (DLC listings are for owned games), sorted after wishlist.

Args:
min_price: Minimum giveaway price in points
Expand Down Expand Up @@ -286,15 +288,21 @@ async def get_eligible(
min_release_date = f"{now.year - max_game_age}-01-01"
filter_conditions.append(Game.release_date >= min_release_date)

# Bypass branches: a giveaway qualifies by passing the full filter
# set, or via an active priority flag (mirrors evaluate_eligibility).
qualifying: list[ColumnElement[bool]] = [and_(*filter_conditions)]
order_by: list[ColumnElement[Any]] = []
if wishlist_priority:
query = query.where(
and_(*base_conditions),
or_(self.model.is_wishlist == True, and_(*filter_conditions)), # noqa: E712
).order_by(self.model.is_wishlist.desc(), self.model.price.desc())
else:
query = query.where(
and_(*base_conditions), and_(*filter_conditions)
).order_by(self.model.price.desc())
qualifying.append(self.model.is_wishlist == True) # noqa: E712
order_by.append(self.model.is_wishlist.desc())
if dlc_priority:
qualifying.append(self.model.is_dlc == True) # noqa: E712
order_by.append(self.model.is_dlc.desc())
order_by.append(self.model.price.desc())

query = query.where(
and_(*base_conditions), or_(*qualifying)
).order_by(*order_by)

if limit:
query = query.limit(limit)
Expand Down Expand Up @@ -436,16 +444,19 @@ async def get_wishlist(
result = await self.session.execute(query)
return list(result.scalars().all())

async def unset_wishlist_except(self, codes: set[str]) -> int:
async def unset_flag_except(self, flag: str, codes: set[str]) -> int:
"""
Clear the wishlist flag on active giveaways not in ``codes``.
Clear a scan-derived flag on active giveaways not in ``codes``.

Called after a *complete* wishlist scan: any active giveaway still
flagged wishlist but absent from the scan is no longer on the user's
Steam wishlist. Expired giveaways keep their flag as history.
Called after a *complete* scan of the matching type: any active
giveaway still flagged but absent from the scan no longer belongs to
that listing (e.g. game removed from the Steam wishlist, or no longer
shown as owned-game DLC). Expired giveaways keep their flag as
history.

Args:
codes: Giveaway codes seen in the completed wishlist scan
flag: Column name, "is_wishlist" or "is_dlc"
codes: Giveaway codes seen in the completed scan

Returns:
Number of giveaways whose flag was cleared
Expand All @@ -454,16 +465,20 @@ async def unset_wishlist_except(self, codes: set[str]) -> int:
This method does NOT commit the transaction. The caller must
call session.commit() to persist changes to the database.
"""
if flag not in ("is_wishlist", "is_dlc"):
raise ValueError(f"Unsupported scan flag: {flag}")
column = getattr(self.model, flag)

now = utcnow()
stmt = (
update(self.model)
.where(
self.model.is_wishlist == True, # noqa: E712
column == True, # noqa: E712
self.model.end_time.isnot(None),
self.model.end_time > now,
self.model.code.notin_(codes),
)
.values(is_wishlist=False)
.values(**{flag: False})
)
result = await self.session.execute(stmt)
# execute() is typed as Result, but UPDATE always yields a CursorResult.
Expand Down
10 changes: 10 additions & 0 deletions backend/src/services/eligibility.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@
game-quality criteria entirely — being on the user's Steam wishlist already
answers the question those filters approximate. They still respect the
active/hidden/entered checks.

DLC exception (same reasoning): SteamGifts' DLC listing only shows DLC for
games the user owns, so ``is_dlc`` giveaways bypass the same filters when
``dlc_priority`` is on (driven by the dlc_priority_enabled setting).
"""

from dataclasses import dataclass
Expand Down Expand Up @@ -67,6 +71,7 @@ class EligibilityCriteria:
min_reviews: int | None = None
max_game_age: int | None = None
wishlist_priority: bool = True
dlc_priority: bool = False

@property
def needs_game_data(self) -> bool:
Expand Down Expand Up @@ -107,6 +112,11 @@ def evaluate_eligibility(giveaway: Any, game: Any, criteria: EligibilityCriteria
if criteria.wishlist_priority and giveaway.is_wishlist:
return ELIGIBLE

# DLC listings are for games the user owns — same bypass, gated by the
# dlc_priority_enabled setting.
if criteria.dlc_priority and giveaway.is_dlc:
return ELIGIBLE

# Price range — matches `price >= min_price [AND price <= max_price]`.
if giveaway.price < criteria.min_price:
return PRICE_BELOW_MIN
Expand Down
11 changes: 8 additions & 3 deletions backend/src/services/giveaway_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ async def get_eligible_giveaways(
max_game_age: int | None = None,
limit: int = 50,
wishlist_priority: bool = True,
dlc_priority: bool = False,
) -> list[Giveaway]:
"""
Get eligible giveaways based on criteria.
Expand Down Expand Up @@ -101,6 +102,7 @@ async def get_eligible_giveaways(
max_game_age=max_game_age,
limit=limit,
wishlist_priority=wishlist_priority,
dlc_priority=dlc_priority,
)

return giveaways
Expand Down Expand Up @@ -149,9 +151,12 @@ async def evaluate_and_get_eligible(
logger.info("eligibility_evaluated", total=len(candidates), **dict(counts))

# candidates are ordered by price desc; the stable sort moves wishlist
# giveaways to the front while keeping price order within each group.
if criteria.wishlist_priority:
eligible.sort(key=lambda g: not g.is_wishlist)
# (then DLC) giveaways to the front while keeping price order within
# each group.
eligible.sort(key=lambda g: (
not (criteria.wishlist_priority and g.is_wishlist),
not (criteria.dlc_priority and g.is_dlc),
))
return eligible[:limit] if limit else eligible

async def get_active_giveaways(
Expand Down
27 changes: 18 additions & 9 deletions backend/src/services/giveaway_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,13 +132,19 @@ async def sync_giveaways(
scan_complete = True
break

# A complete wishlist scan is the source of truth for the wishlist
# flag: clear it on active giveaways that no longer appear (the game
# was removed from the Steam wishlist). Never after partial scans.
if giveaway_type == "wishlist" and scan_complete:
cleared = await self.giveaway_repo.unset_wishlist_except(scraped_codes)
if cleared:
logger.info("wishlist_flags_cleared", count=cleared)
# A complete wishlist/DLC scan is the source of truth for its flag:
# clear it on active giveaways that no longer appear (the game was
# removed from the wishlist / is no longer listed as owned-game DLC).
# Never after partial scans.
if scan_complete:
if giveaway_type == "wishlist":
cleared = await self.giveaway_repo.unset_flag_except("is_wishlist", scraped_codes)
if cleared:
logger.info("wishlist_flags_cleared", count=cleared)
elif dlc_only:
cleared = await self.giveaway_repo.unset_flag_except("is_dlc", scraped_codes)
if cleared:
logger.info("dlc_flags_cleared", count=cleared)

await self.session.commit()
return new_count, updated_count
Expand Down Expand Up @@ -278,6 +284,7 @@ async def _create_giveaway(self, ga_data: dict) -> Giveaway:
end_time=ga_data.get("end_time"),
game_id=ga_data.get("game_id"),
is_wishlist=ga_data.get("is_wishlist", False),
is_dlc=ga_data.get("is_dlc", False),
is_entered=ga_data.get("is_entered", False),
)
return giveaway
Expand All @@ -301,8 +308,10 @@ async def _update_giveaway(self, giveaway: Giveaway, ga_data: dict) -> None:
if ga_data.get("game_id") and not giveaway.game_id:
giveaway.game_id = ga_data["game_id"]

# Set the wishlist flag when this row came from a wishlist scan.
# Set the wishlist/DLC flags when this row came from those scans.
# The reverse direction (un-sticking removed games) is handled by
# unset_wishlist_except after a complete wishlist scan.
# unset_flag_except after a complete scan of the same type.
if ga_data.get("is_wishlist"):
giveaway.is_wishlist = True
if ga_data.get("is_dlc"):
giveaway.is_dlc = True
2 changes: 1 addition & 1 deletion backend/src/services/settings_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ async def reset_to_defaults(self) -> Settings:
user_agent=user_agent,
xsrf_token=xsrf_token,
# Reset DLC settings
dlc_enabled=False,
dlc_priority_enabled=False,
# Reset autojoin settings
autojoin_enabled=False,
autojoin_start_at=350, # Integer default (point threshold)
Expand Down
4 changes: 3 additions & 1 deletion backend/src/utils/steamgifts_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,9 @@ async def get_giveaways(
)

giveaways = parser.parse_giveaway_list(
response.text, mark_wishlist=giveaway_type == "wishlist"
response.text,
mark_wishlist=giveaway_type == "wishlist",
mark_dlc=dlc_only,
)

# Zero rows without the explicit "No results were found." marker means
Expand Down
Loading
Loading