feat: Jellyfin/Plex auto-import via watch queue
Some checks failed
CI / Check / Test (push) Failing after 6m5s
Some checks failed
CI / Check / Test (push) Failing after 6m5s
Webhook ingestion from media servers — movies land in a pending watch queue, user rates and confirms to create diary entries. - domain: WatchEvent, WebhookToken models, MediaServerParser port - adapters: jellyfin + plex parser crates, SQLite + Postgres repos - application: ingest/confirm/dismiss/cleanup use cases, token mgmt - presentation: webhook endpoints (bearer + query param auth), watch queue + integrations settings HTML pages, OpenAPI docs - worker: WatchEventCleanupJob (daily, 30d retention) Movie resolution deferred to confirm — single canonical path through log_review for enrichment, poster fetch, federation.
This commit is contained in:
@@ -2,8 +2,9 @@ use application::ports::{
|
||||
ActivityFeedPageData, BlockedActorEntry, BlockedActorsPageData, BlockedDomainEntry,
|
||||
BlockedDomainsPageData, FollowersPageData, FollowingPageData, HtmlPageContext, HtmlRenderer,
|
||||
ImportMappingPageData, ImportPreviewPageData, ImportPreviewRow, ImportProfileView,
|
||||
ImportRowStatus, ImportUploadPageData, LoginPageData, MovieDetailPageData, NewReviewPageData,
|
||||
ProfilePageData, ProfileSettingsPageData, RegisterPageData, UsersPageData, WatchlistPageData,
|
||||
ImportRowStatus, ImportUploadPageData, IntegrationsPageData, LoginPageData,
|
||||
MovieDetailPageData, NewReviewPageData, ProfilePageData, ProfileSettingsPageData,
|
||||
RegisterPageData, UsersPageData, WatchQueuePageData, WatchlistPageData, WebhookTokenView,
|
||||
};
|
||||
use askama::Template;
|
||||
use chrono::Datelike;
|
||||
@@ -366,6 +367,23 @@ struct ProfileSettingsTemplate<'a> {
|
||||
saved: bool,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "integrations.html")]
|
||||
struct IntegrationsTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
tokens: &'a [WebhookTokenView],
|
||||
webhook_base_url: &'a str,
|
||||
new_token: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "watch_queue.html")]
|
||||
struct WatchQueueTemplate<'a> {
|
||||
ctx: &'a HtmlPageContext,
|
||||
entries: &'a [application::ports::WatchQueueDisplayEntry],
|
||||
error: Option<&'a str>,
|
||||
}
|
||||
|
||||
#[derive(Template)]
|
||||
#[template(path = "import_upload.html")]
|
||||
struct ImportUploadTemplate<'a> {
|
||||
@@ -750,4 +768,25 @@ impl HtmlRenderer for AskamaHtmlRenderer {
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_integrations_page(&self, data: IntegrationsPageData) -> Result<String, String> {
|
||||
IntegrationsTemplate {
|
||||
ctx: &data.ctx,
|
||||
tokens: &data.tokens,
|
||||
webhook_base_url: &data.webhook_base_url,
|
||||
new_token: data.new_token.as_deref(),
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn render_watch_queue_page(&self, data: WatchQueuePageData) -> Result<String, String> {
|
||||
WatchQueueTemplate {
|
||||
ctx: &data.ctx,
|
||||
entries: &data.entries,
|
||||
error: data.error.as_deref(),
|
||||
}
|
||||
.render()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<a href="/users/{{ uid }}">Profile</a>
|
||||
<a href="/reviews/new">Add Review</a>
|
||||
<a href="/import">Import</a>
|
||||
<a href="/watch-queue">Queue</a>
|
||||
<a href="/logout">Logout</a>
|
||||
{% else %}
|
||||
<a href="/login">Login</a>
|
||||
|
||||
92
crates/adapters/template-askama/templates/integrations.html
Normal file
92
crates/adapters/template-askama/templates/integrations.html
Normal file
@@ -0,0 +1,92 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Integrations</h1>
|
||||
<p style="font-size:.85em;opacity:.7;margin-bottom:1rem">
|
||||
<a href="/settings/profile">Profile Settings</a>
|
||||
</p>
|
||||
|
||||
<section style="margin-bottom:2rem">
|
||||
<h2 style="font-size:1.1em">Jellyfin / Plex Webhook</h2>
|
||||
<p style="opacity:.7;font-size:.9em">
|
||||
Automatically log movies you finish watching. Configure your media server's
|
||||
webhook plugin to POST to the URL below.
|
||||
</p>
|
||||
|
||||
<div style="margin:1rem 0;padding:0.75rem 1rem;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:8px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Jellyfin Webhook URL</label>
|
||||
<code style="word-break:break-all">{{ webhook_base_url }}/api/v1/webhooks/jellyfin</code>
|
||||
</div>
|
||||
|
||||
<div style="margin:1rem 0;padding:0.75rem 1rem;background:var(--glass-bg);border:1px solid var(--glass-border);border-radius:8px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Plex Webhook URL (append token as query param)</label>
|
||||
<code style="word-break:break-all">{{ webhook_base_url }}/api/v1/webhooks/plex?token=YOUR_TOKEN</code>
|
||||
</div>
|
||||
|
||||
<details style="margin:1rem 0;font-size:.85em;opacity:.8">
|
||||
<summary style="cursor:pointer">Jellyfin setup</summary>
|
||||
<ol style="margin-top:0.5rem;padding-left:1.2rem">
|
||||
<li>Install the <strong>Webhook</strong> plugin (Dashboard → Plugins → Catalog)</li>
|
||||
<li>Add Generic Destination with the Jellyfin URL above</li>
|
||||
<li>Add header: <code>Authorization</code> = <code>Bearer YOUR_TOKEN</code></li>
|
||||
<li>Check <strong>Send All Properties</strong></li>
|
||||
<li>Notification Type: <strong>Playback Stop</strong> only</li>
|
||||
<li>Item Type: <strong>Movies</strong> only</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
<details style="margin:1rem 0;font-size:.85em;opacity:.8">
|
||||
<summary style="cursor:pointer">Plex setup (requires Plex Pass)</summary>
|
||||
<ol style="margin-top:0.5rem;padding-left:1.2rem">
|
||||
<li>Go to Settings → Webhooks in your Plex server</li>
|
||||
<li>Add the Plex URL above, replacing <code>YOUR_TOKEN</code> with your generated token</li>
|
||||
<li>Plex automatically sends scrobble events when a movie is watched to 90%+</li>
|
||||
</ol>
|
||||
</details>
|
||||
|
||||
{% if let Some(token) = new_token %}
|
||||
<div style="margin:1rem 0;padding:0.75rem 1rem;background:rgba(229,192,52,.1);border:1px solid rgba(229,192,52,.3);border-radius:8px">
|
||||
<strong>New token (copy now — shown only once):</strong><br>
|
||||
<code style="word-break:break-all;font-size:1.1em">{{ token }}</code>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/settings/integrations/generate" style="display:flex;gap:0.5rem;align-items:flex-end;margin:1rem 0">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<input type="hidden" name="provider" value="jellyfin">
|
||||
<div style="flex:1;min-width:150px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Label (optional)</label>
|
||||
<input type="text" name="label" placeholder="e.g. Living Room Server" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<button type="submit" class="btn-small" style="height:2.25rem">Generate Token</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{% if !tokens.is_empty() %}
|
||||
<section>
|
||||
<h2 style="font-size:1.1em">Active Tokens</h2>
|
||||
<div class="diary">
|
||||
{% for t in tokens %}
|
||||
<article class="entry" style="padding:0.75rem 1rem">
|
||||
<div class="entry-body">
|
||||
<div class="entry-title" style="font-size:0.95em">
|
||||
{{ t.provider }}{% if let Some(l) = &t.label %} — {{ l }}{% endif %}
|
||||
</div>
|
||||
<div class="feed-meta" style="margin-top:0.3rem">
|
||||
<span class="feed-time">Created {{ t.created_at }}</span>
|
||||
{% if let Some(used) = &t.last_used_at %}
|
||||
<span class="feed-time" style="margin-left:0.5rem">Last used {{ used }}</span>
|
||||
{% else %}
|
||||
<span class="feed-time" style="margin-left:0.5rem;opacity:.5">Never used</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<form method="post" action="/settings/integrations/{{ t.id }}/revoke" style="margin-top:0.5rem">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3)">Revoke</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -70,6 +70,7 @@
|
||||
<h3>Account</h3>
|
||||
<a href="/users/{{ profile_user_id }}/watchlist">Watchlist</a>
|
||||
<a href="/settings/profile">Profile settings</a>
|
||||
<a href="/settings/integrations">Integrations</a>
|
||||
<a href="/social/blocked">Blocked users</a>
|
||||
{% if ctx.is_admin %}
|
||||
<a href="/admin/blocked-domains">Admin — blocked domains</a>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h1>Profile Settings</h1>
|
||||
<p style="font-size:.85em;opacity:.7;margin-bottom:1rem">
|
||||
<a href="/settings/integrations">Integrations (Jellyfin/Plex)</a>
|
||||
</p>
|
||||
{% if saved %}
|
||||
<p class="success">Saved.</p>
|
||||
{% endif %}
|
||||
|
||||
68
crates/adapters/template-askama/templates/watch_queue.html
Normal file
68
crates/adapters/template-askama/templates/watch_queue.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="movie-detail">
|
||||
<div class="entry-title" style="margin-bottom:1rem">Watch Queue</div>
|
||||
|
||||
{% if let Some(err) = error %}
|
||||
<p class="form-error">{{ err }}</p>
|
||||
{% endif %}
|
||||
|
||||
{% if entries.is_empty() %}
|
||||
<p class="empty">
|
||||
No pending watches.
|
||||
<a href="/settings/integrations">Connect Jellyfin</a> to start auto-logging.
|
||||
</p>
|
||||
{% else %}
|
||||
<p style="opacity:.7;font-size:.85em;margin-bottom:1rem">
|
||||
Movies you watched via Jellyfin. Rate and confirm to add to your diary, or dismiss.
|
||||
</p>
|
||||
<div class="diary">
|
||||
{% for entry in entries %}
|
||||
<article class="entry">
|
||||
<div class="entry-body">
|
||||
<div class="entry-title">
|
||||
{% if let Some(url) = &entry.movie_url %}
|
||||
<a href="{{ url }}" class="movie-title-link">{{ entry.title }}</a>
|
||||
{% else %}
|
||||
{{ entry.title }}
|
||||
{% endif %}
|
||||
{% if let Some(y) = entry.year %}
|
||||
<span class="year">({{ y }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="feed-meta" style="margin-top:0.3rem">
|
||||
<span class="feed-time">Watched {{ entry.watched_at }}</span>
|
||||
<span class="feed-time" style="margin-left:0.5rem;opacity:.6">via {{ entry.source }}</span>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/watch-queue/{{ entry.id }}/confirm" style="margin-top:0.6rem;display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<div>
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Rating</label>
|
||||
<select name="rating" style="width:auto">
|
||||
<option value="0">—</option>
|
||||
<option value="1">1★</option>
|
||||
<option value="2">2★</option>
|
||||
<option value="3">3★</option>
|
||||
<option value="4">4★</option>
|
||||
<option value="5">5★</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:1;min-width:120px">
|
||||
<label style="display:block;font-size:0.8em;opacity:.6;margin-bottom:0.25rem">Comment</label>
|
||||
<input type="text" name="comment" placeholder="Optional" style="width:100%;box-sizing:border-box">
|
||||
</div>
|
||||
<button type="submit" class="btn-small" style="height:2.25rem">Confirm</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/watch-queue/{{ entry.id }}/dismiss" style="margin-top:0.3rem">
|
||||
<input type="hidden" name="_csrf" value="{{ ctx.csrf_token }}">
|
||||
<button type="submit" class="btn-small" style="color:#e57a7a;border-color:rgba(229,122,122,.3);font-size:.8em">Dismiss</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user