BfiMonitor.csproj
+2
-1
diff --git a/BfiMonitor.csproj b/BfiMonitor.csproj
index 823e81e..38982ea 100644
@@ -2,8 +2,8 @@
<PropertyGroup>
<TargetFramework>net11.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<PublishAot>true</PublishAot>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
@@ -22,6 +22,7 @@
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="11.0.0-preview.3.26207.106" />
<PackageReference Include="Microsoft.Playwright" Version="1.59.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="9.0.*" />
<PackageReference Include="Quartz.Extensions.Hosting" Version="3.18.1" />
</ItemGroup>
</Project>
BfiScreeningCheckerJob.cs
+65
-0
diff --git a/BfiScreeningCheckerJob.cs b/BfiScreeningCheckerJob.cs
new file mode 100644
index 0000000..28325b5
@@ -0,0 +1,65 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Quartz;
[DisallowConcurrentExecution]
internal sealed class BfiScreeningCheckerJob(
PlaywrightBrowserService browserService,
ScreeningRepository repository,
IScheduler scheduler,
IOptions<MonitorOptions> options,
ILogger<BfiScreeningCheckerJob> logger
) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var opts = options.Value;
var page = await browserService.NewPageAsync();
try
{
await page.GotoAsync(opts.Url);
// Booking links on BFI What's On pages have "book" in the href.
// Each href is unique per performance and encodes the date/time/seat map.
var bookingLinks = await page.QuerySelectorAllAsync("a[href*='book' i], a[href*='seatmap' i]");
var available = new List<string>();
foreach (var link in bookingLinks)
{
var href = await link.GetAttributeAsync("href");
if (!string.IsNullOrWhiteSpace(href))
available.Add(href);
}
logger.LogInformation("Check complete — {Count} booking link(s) found on {Url}", available.Count, opts.Url);
foreach (var href in available)
{
if (await repository.HasBeenNotifiedAsync(href))
continue;
await repository.MarkNotifiedAsync(href);
var message = $"BFI IMAX tickets available! Book now: {href}";
foreach (var phone in opts.PhoneNumbers)
{
var dataMap = new JobDataMap { { "phoneNumber", phone }, { "message", message } };
var job = JobBuilder
.Create<SendSmsJob>()
.WithIdentity(Guid.NewGuid().ToString(), "sms")
.UsingJobData(dataMap)
.Build();
var trigger = TriggerBuilder.Create().StartNow().Build();
await scheduler.ScheduleJob(job, trigger, context.CancellationToken);
logger.LogInformation("Scheduled SMS to {Phone} for screening {Href}", phone, href);
}
}
}
finally
{
await page.CloseAsync();
}
}
}
MonitorOptions.cs
+7
-0
diff --git a/MonitorOptions.cs b/MonitorOptions.cs
new file mode 100644
index 0000000..9035b40
@@ -0,0 +1,7 @@
internal sealed class MonitorOptions
{
public int IntervalSeconds { get; set; } = 300;
public string Url { get; set; } = "";
public string[] PhoneNumbers { get; set; } = [];
public string SmsSenderAddress { get; set; } = "http://localhost:50051";
}
PlaywrightBrowserService.cs
+25
-0
diff --git a/PlaywrightBrowserService.cs b/PlaywrightBrowserService.cs
new file mode 100644
index 0000000..6d29a64
@@ -0,0 +1,25 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Playwright;
internal sealed class PlaywrightBrowserService : IHostedService, IAsyncDisposable
{
private IPlaywright? _playwright;
private IBrowser? _browser;
public async Task StartAsync(CancellationToken cancellationToken)
{
_playwright = await Playwright.CreateAsync();
_browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions { Headless = true });
}
public async Task StopAsync(CancellationToken cancellationToken) => await DisposeAsync();
public Task<IPage> NewPageAsync() => _browser!.NewPageAsync();
public async ValueTask DisposeAsync()
{
if (_browser is not null)
await _browser.CloseAsync();
_playwright?.Dispose();
}
}
Program.cs
+31
-2
diff --git a/Program.cs b/Program.cs
index 3967196..7bbfc9d 100644
@@ -1,14 +1,43 @@
using HuaweiWifiSms.Grpc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Quartz;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.Configure<MonitorOptions>(builder.Configuration.GetSection("Monitor"));
builder.Services.AddGrpcClient<SmsSender.SmsSenderClient>(
(sp, o) =>
{
var opts = sp.GetRequiredService<IOptions<MonitorOptions>>().Value;
o.Address = new Uri(opts.SmsSenderAddress);
}
);
builder.Services.AddSingleton<PlaywrightBrowserService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<PlaywrightBrowserService>());
builder.Services.AddSingleton<ScreeningRepository>();
var intervalSeconds = builder.Configuration.GetValue<int>("Monitor:IntervalSeconds", 300);
builder.Services.AddQuartz(q =>
{
// Register jobs
var checkerKey = JobKey.Create(nameof(BfiScreeningCheckerJob));
q.AddJob<BfiScreeningCheckerJob>(checkerKey);
q.AddTrigger(t =>
t.ForJob(checkerKey)
.StartNow()
.WithSimpleSchedule(s => s.WithIntervalInSeconds(intervalSeconds).RepeatForever())
);
q.AddJob<SendSmsJob>(JobKey.Create(nameof(SendSmsJob)), j => j.StoreDurably());
});
builder.Services.AddQuartzHostedService();
builder.Services.AddQuartzHostedService(o => o.WaitForJobsToComplete = true);
using var app = builder.Build();
ScreeningRepository.cs
+44
-0
diff --git a/ScreeningRepository.cs b/ScreeningRepository.cs
new file mode 100644
index 0000000..fb6d65b
@@ -0,0 +1,44 @@
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
internal sealed class ScreeningRepository
{
private readonly string _connectionString;
public ScreeningRepository(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("Screenings") ?? "DataSource=screenings.db";
Initialize();
}
private void Initialize()
{
using var connection = new SqliteConnection(_connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
"CREATE TABLE IF NOT EXISTS NotifiedScreenings (Id TEXT PRIMARY KEY, NotifiedAt TEXT NOT NULL)";
command.ExecuteNonQuery();
}
public async Task<bool> HasBeenNotifiedAsync(string screeningId)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = "SELECT 1 FROM NotifiedScreenings WHERE Id = $id";
command.Parameters.AddWithValue("$id", screeningId);
return await command.ExecuteScalarAsync() is not null;
}
public async Task MarkNotifiedAsync(string screeningId)
{
await using var connection = new SqliteConnection(_connectionString);
await connection.OpenAsync();
using var command = connection.CreateCommand();
command.CommandText = "INSERT OR IGNORE INTO NotifiedScreenings (Id, NotifiedAt) VALUES ($id, $at)";
command.Parameters.AddWithValue("$id", screeningId);
command.Parameters.AddWithValue("$at", DateTimeOffset.UtcNow.ToString("O"));
await command.ExecuteNonQueryAsync();
}
}
SendSmsJob.cs
+22
-0
diff --git a/SendSmsJob.cs b/SendSmsJob.cs
new file mode 100644
index 0000000..aad8cd2
@@ -0,0 +1,22 @@
using HuaweiWifiSms.Grpc;
using Microsoft.Extensions.Logging;
using Quartz;
internal sealed class SendSmsJob(SmsSender.SmsSenderClient smsSender, ILogger<SendSmsJob> logger) : IJob
{
public async Task Execute(IJobExecutionContext context)
{
var phone = context.MergedJobDataMap.GetString("phoneNumber")!;
var message = context.MergedJobDataMap.GetString("message")!;
var response = await smsSender.SendSmsAsync(
new SmsRequest { RecipientPhoneNumber = phone, Content = message },
cancellationToken: context.CancellationToken
);
if (response.Status == SmsStatus.Success)
logger.LogInformation("SMS sent to {Phone}", phone);
else
logger.LogWarning("SMS to {Phone} returned status {Status}", phone, response.Status);
}
}
appsettings.json
+9
-0
diff --git a/appsettings.json b/appsettings.json
index 0c208ae..9e60228 100644
@@ -1,4 +1,13 @@
{
"ConnectionStrings": {
"Screenings": "DataSource=screenings.db"
},
"Monitor": {
"IntervalSeconds": 300,
"Url": "https://whatson.bfi.org.uk/imax/Online/default.asp?BOparam::WScontent::loadArticle::permalink=odyssey-the-film-imax-70mm-2026",
"PhoneNumbers": [],
"SmsSenderAddress": "http://localhost:50051"
},
"Logging": {
"LogLevel": {
"Default": "Information",