A .NET 9 companion server for the MeTube app that efficiently manages YouTube channel subscriptions and video metadata through WebSub push notifications and periodic reconciliation.
The MeTube Hub Server acts as a middleware between YouTube and the MeTube app to:
- Minimize YouTube API quota usage by caching channel and video data
- Receive push notifications from YouTube via WebSub for real-time updates
- Provide fast, aggregated "new videos" feeds to MeTube clients
- Support multiple users with shared channel subscriptions
YouTube → WebSub Hub → MeTube Hub Server → MeTube App
↑ ↓
YouTube Data API (caching)
- WebSub Push Notifications: Real-time video updates via YouTube's PubSubHubbub
- Automatic Subscription Management: Maintains WebSub subscriptions with lease renewal
- Reconciliation: Periodic polling to backfill missed notifications
- Multi-user Support: One WebSub subscription per channel, shared across users
- HMAC Security: Verifies WebSub notifications with constant-time comparison
- Rate Limiting: Built-in protection against abuse
- Background Task Queue: Reliable async operations with proper scope management
- Transaction Support: Database consistency for multi-step operations
- Pagination: Efficient feed pagination with cursor support
- Channel Metadata: Stores channel names and thumbnails
- .NET 9.0 - ASP.NET Core Minimal APIs
- Entity Framework Core 9.0 - ORM with SQLite provider
- SQLite - Local database (easily swappable with PostgreSQL)
- YouTube Data API v3 - Channel and video metadata
- .NET 9.0 SDK
- YouTube Data API key (Get one here)
- Public HTTPS endpoint for WebSub callbacks
{
"ConnectionStrings": {
"DefaultConnection": "Data Source=metubeserver.db"
},
"YouTube": {
"ApiKey": "YOUR_YOUTUBE_API_KEY",
"HubUrl": "https://pubsubhubbub.appspot.com/subscribe",
"CallbackBaseUrl": "https://your-server-url.com"
},
"Hub": {
"WebSubSharedSecretLength": 32,
"DefaultFeedLimit": 50,
"MaxFeedLimit": 100,
"ReconciliationMaxResults": 20,
"MaxRequestBodySize": 1048576,
"HttpClientTimeoutSeconds": 30,
"ShutdownTimeoutSeconds": 30
}
}Hub Configuration Options:
WebSubSharedSecretLength: Length of generated WebSub secrets (default: 32)DefaultFeedLimit: Default number of videos in feed response (default: 50)MaxFeedLimit: Maximum allowed feed limit (default: 100)ReconciliationMaxResults: Max results per reconciliation job (default: 20)MaxRequestBodySize: Maximum request body size in bytes (default: 1 MB)HttpClientTimeoutSeconds: HTTP client timeout (default: 30)ShutdownTimeoutSeconds: Graceful shutdown timeout (default: 30)
You can override configuration via environment variables:
YouTube__ApiKey- Your YouTube Data API keyYouTube__CallbackBaseUrl- Public URL where this server is accessibleConnectionStrings__DefaultConnection- Database connection string
- Clone the repository:
git clone https://github.com/pardeike/MeTubeServer.git
cd MeTubeServer- Configure the application:
# Edit appsettings.json or set environment variables
export YouTube__ApiKey="YOUR_API_KEY"
export YouTube__CallbackBaseUrl="https://your-ngrok-url.com"- Run the application:
dotnet rundocker build -t metubeserver .
docker run -p 5000:8080 \
-e YouTube__ApiKey="YOUR_API_KEY" \
-e YouTube__CallbackBaseUrl="https://your-domain.com" \
metubeserverOr use docker-compose (recommended):
cp .env.example .env
# Edit .env with your values
docker-compose up -d- Install Cloudflare Tunnel:
cloudflared tunnel login
cloudflared tunnel create metubeserver- Run the server with tunnel:
cloudflared tunnel --url http://localhost:5000 run metubeserverHealth check endpoint for monitoring server status and quota usage.
Response (Healthy):
{
"status": "healthy",
"timestamp": "2025-12-06T12:00:00Z",
"stats": {
"channels": 10,
"users": 5,
"videos": 250
},
"quota": {
"used": 4920,
"remaining": 5080,
"limit": 10000,
"percentUsed": 49.2
}
}Response (Unhealthy):
{
"status": "unhealthy",
"message": "Database connection failed"
}Detailed health check with additional quota date information.
Response:
{
"status": "healthy",
"timestamp": "2025-12-06T12:00:00Z",
"stats": {
"channels": 10,
"users": 5,
"videos": 250
},
"quota": {
"used": 4920,
"remaining": 5080,
"limit": 10000,
"percentUsed": 49.2,
"date": "2025-12-06"
}
}WebSub verification endpoint. Called by YouTube's hub to verify subscriptions.
Query Parameters:
hub.mode- "subscribe" or "unsubscribe"hub.topic- YouTube feed URLhub.challenge- Challenge string to echo backhub.lease_seconds- Subscription lease duration
Response: Returns the challenge string in plain text.
WebSub notification endpoint. Receives Atom feed notifications when new videos are published.
Headers:
X-Hub-SignatureorX-Hub-Signature-256- HMAC signature for verification
Body: Atom XML feed with video entries
Register channel subscriptions for a user.
Request Body:
{
"channelIds": ["UC123...", "UC456..."]
}Response:
{
"message": "Channels registered successfully"
}Trigger on-demand reconciliation for a user's channels. Call this when the user pulls to refresh or the app comes to foreground.
Response:
{
"message": "Reconciliation completed",
"newVideosCount": 5
}Quota Impact: 1 unit per channel × number of user's channels. Only reconciles when user actively requests it, dramatically reducing quota usage compared to automatic polling.
Get the user's aggregated video feed.
Query Parameters:
since(optional) - ISO 8601 timestamp, returns videos published after this timelimit(optional, default: 50, max: 100) - Maximum number of videos to return
Response:
{
"videos": [
{
"videoId": "abc123",
"channelId": "UC123...",
"publishedAt": "2025-12-06T12:00:00Z",
"title": "Video Title",
"description": "Video description...",
"thumbnailUrl": "https://...",
"duration": "00:04:13"
}
],
"nextCursor": "2025-12-06T11:00:00Z",
"nextPageToken": null
}Note: Use nextCursor for pagination. If present, pass it as the since parameter to get the next page.
- Channel: YouTube channels with WebSub subscription info
- User: MeTube app users
- UserChannel: Many-to-many relationship between users and channels
- Video: Cached video metadata
Channels
- Id (PK)
- ChannelId (unique)
- TopicUrl (unique)
- UploadsPlaylistId
- HubSecret
- LeaseExpiresAt
- LastSeenPublishedAt
Users
- Id (PK)
- AppUserId (unique)
UserChannels
- UserId (FK)
- ChannelId (FK)
- UserLastSeenPublishedAt
Videos
- Id (PK)
- VideoId (unique)
- ChannelId (FK)
- PublishedAt
- Title
- Description
- ThumbnailUrl
- Duration
- Frequency: Every hour
- Purpose: Renews WebSub subscriptions before they expire
- Logic: Renews subscriptions that expire within 24 hours
- Frequency: Every 30 minutes
- Purpose: Backfills videos missed during downtime or WebSub failures
- Logic: Queries YouTube Data API for recent uploads per channel
- Globally distributed edge computing
- Minimal latency
- Free tier available
- Cloudflare D1 for database
- Full control over infrastructure
- Easy debugging
- SQLite database on disk
- Protected by Cloudflare Tunnel
- Any cloud provider (AWS, Azure, GCP, DigitalOcean, etc.)
- PostgreSQL or SQLite database
- Docker deployment recommended
The server minimizes YouTube API quota usage through caching, WebSub push notifications, and on-demand reconciliation:
- WebSub subscriptions: No quota cost (push-based, real-time updates)
- On-demand reconciliation: Only when user refreshes (1 unit per channel)
- channels.list: 1 unit per channel (one-time per channel)
- videos.list: 1 unit per call (batched up to 50 video IDs)
Daily Usage Examples:
- 10 channels, 10 users, 5 refreshes/user/day: ~610 units/day (6% of 10,000 limit) ✅
- 100 channels, 50 users, 5 refreshes/user/day: ~2,610 units/day (26% of limit) ✅
- 200 channels, 20 users, 10 refreshes/user/day: ~2,220 units/day (22% of limit) ✅
Key Change: Automatic reconciliation removed. App must call POST /api/users/{userId}/reconcile on pull-to-refresh or foreground transition to check for missed videos.
Monitoring:
- Check quota usage via
/healthor/health/detailsendpoints - Quota information includes: used, remaining, limit, and percentage
- Server logs warnings when usage exceeds 80%
- Quota resets daily at midnight Pacific Time
📖 For detailed quota information, optimization strategies, and troubleshooting, see QUOTA.md and QUOTA_FUNCTIONS.md
The server logs:
- WebSub verification requests and notifications
- Channel subscription/unsubscription events
- Video additions and updates
- YouTube API errors
- HMAC verification failures
Log levels can be configured in appsettings.json.
- HMAC Verification: All WebSub notifications are verified using HMAC-SHA1/SHA256 with constant-time comparison
- No User Credentials: Server never stores user OAuth tokens
- Public Endpoint: Only public YouTube data is cached
- Rate Limiting: Built-in rate limiting protects against abuse:
- General API endpoints: 100 requests per minute per IP
- WebSub endpoint: 50 requests per minute per IP
- Request Size Limits: Request bodies are limited to 1 MB by default
- Input Validation: Request payloads are validated for correctness
- Verify
YouTube__CallbackBaseUrlis publicly accessible via HTTPS - Check server logs for verification requests
- Ensure firewall allows incoming HTTPS traffic
- Verify WebSub hub can reach your callback URL
- Check reconciliation job logs
- Verify YouTube API key is valid and has quota
- Check
LastSeenPublishedAttimestamps in database - Run reconciliation manually to backfill
- Check current usage:
curl http://localhost:5000/health - Review logs for high-cost operations:
grep "quota" logs/*.log - If near limit (>80%), consider:
- Adjusting reconciliation frequency (see QUOTA.md)
- Requesting quota increase from Google
- Quota resets daily at midnight Pacific Time
For detailed quota troubleshooting, see QUOTA.md.
- Ensure write permissions for SQLite database file
- Check connection string in configuration
- Verify database file exists and is not corrupted
dotnet builddotnet testrm metubeserver.db
dotnet runContributions are welcome! Please open an issue or pull request.
See LICENSE file for details.
- MeTube iOS App - The companion iOS app
For issues and questions, please open an issue on GitHub.