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
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,34 @@ the log, the lane badge and Activity:

Tests: `tests/test_compute_cards.py`.

**Netflix viewing history for household members, counted like Plex.** Settings -> Users ->
Netflix history import (`services/netflix_import.py`, `services/netflix_match.py`):
- **Both exports:**
- the data export's zip (only `CONTENT_INTERACTION/ViewingActivity.csv` is read: every
profile, start time, watched time, bookmark, device; trailers, previews and starts under two
minutes are left out);
- a profile's `NetflixViewingHistory.csv` (title and day, in the account's locale; the date
format is told by the file's newest-first order).
- **Profiles to members:** each profile found is attached to a household member, or skipped.
Importing the same files again adds nothing.
- **The same rows as Plex:** `watch_history` with `source="netflix"`, stamped when the play ended.
The real watched time is the played time (`listened_ms`); the position reached is the offset.
A day-only play counts for its day and weekday, never for a clock hour or a sitting
(`date_only`).
- **On the library's titles:** a film takes the key its Plex plays use (else `radarr:N`); a
series takes Sonarr's title and category. The match is exact: by title, else through TMDB in
English and German ("Haus des Geldes" is Money Heist). A missing episode number comes from
Sonarr's episode list, else TMDB's list of the season in both languages ("Lügen" is Dark
S1E2). Titles that arrive in the library later are linked by a daily custodian pass
(`netflix_relink`, no network).
- **Counted the same way:** an episode without a number counts by its title
(`viewing_sessions.episode_id`). This applies to the household protection of the deletion
judge, the owner's own watch line, the watch status, comfort replays and series completion.
- **Left alone by the Plex writers:** the sync's resume and in-progress merges, the
re-attribution, the orphan cleanup, the player backfill and the dedupe script.

Tests: `tests/test_netflix_import.py`.

**Wrap-Up v2, step 2: an editor picks the story, a writer tells it.** v1 asked for the whole
story as one JSON form and checked it with rules that were strict on style and loose on truth:
- an honest rounding, a title with an "!" or a band called Journey sent everything back;
Expand Down
4 changes: 0 additions & 4 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,6 @@ app. What the first days of that setup still have to show:
into the member artists, so they stop counting as artists of their own in
the Knowledge Base.

**Household**
- Netflix viewing-history import (CSV) for household members, next to the
Spotify import.

**Discovery features on the taste-vector stack** (SoulSync-inspired)
- **Decade time machine** — "my taste, but the 1980s": taste-similarity
partitioned by era, one lane per decade.
Expand Down
6 changes: 6 additions & 0 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -856,6 +856,12 @@ <h1 tabindex="-1">Settings</h1>
</div>
</div>
</section>
<section class="section mt-12">
<div class="section-head"><h3>Netflix history import</h3><span class="section-hint">A household member's Netflix viewing counts like their Plex viewing. Drop the data export (or a profile's CSV), attach each profile to a member, and import. Importing the same files twice adds nothing.</span></div>
<div class="section-body">
<div id="adm-nf-zone"></div>
</div>
</section>
</section>

</div>
Expand Down
4 changes: 4 additions & 0 deletions frontend/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { loadProfiles } from './kb.js';
import { EL, _errHtml, _errMsg, act, btnBusy, btnDone, confirmDialog, esc, toast } from './ui.js';
import { _swrInvalidate, _swrRun, api } from './api.js';
import { refreshSpotifyPending, spotifyDropZone } from './spotify_import.js';
import { netflixDropZone, refreshNetflixPending } from './netflix_import.js';
export function _renderUsers(users) {
const targets = document.querySelectorAll('.user-list-target');
const html = `<div class="tbl-wrap"><table class="tbl tbl-cards"><thead><tr><th>Username</th><th>Role</th><th>Status</th><th class="t-right">Actions</th></tr></thead><tbody>
Expand Down Expand Up @@ -33,6 +34,9 @@ export async function loadUsers(force = false) {
if (sel) sel.innerHTML = users.map(u =>
`<option value="${u.id}">${esc(u.plex_username)}</option>`).join('');
refreshSpotifyPending('adm-sp');
const nf = document.getElementById('adm-nf-zone');
if (nf && !nf.innerHTML) nf.innerHTML = netflixDropZone();
refreshNetflixPending(users);
} catch (e) {
targets.forEach(el => el.innerHTML = _errHtml(e, act('loadUsers')));
}
Expand Down
6 changes: 6 additions & 0 deletions frontend/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { state } from './state.js';
import { initMobile, installApp, setHapticLevel, toggleTopbarSearch } from './mobile.js';
import { showUpdateInfo } from './version.js';
import { loadNodes, saveNodeName, updateNode } from './nodes.js';
import { clearNetflixFiles, handleNetflixDrop, onNetflixFile, openNetflixPicker, runNetflixImport } from './netflix_import.js';
import { answerGameQuestion } from './game.js';

export function init() {
Expand Down Expand Up @@ -153,6 +154,11 @@ const actions = {
goToView,
handleKey,
handleSpotifyDrop,
handleNetflixDrop,
onNetflixFile,
openNetflixPicker,
runNetflixImport,
clearNetflixFiles,
hideOnError,
posterLoaded,
jumpLatest,
Expand Down
123 changes: 123 additions & 0 deletions frontend/js/netflix_import.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
// ── NETFLIX HISTORY IMPORT (Settings → Users, next to Spotify) ───────────────
// Drop the full data export (the zip, or its ViewingActivity.csv) or a
// profile's NetflixViewingHistory.csv; every profile found is listed with a
// household member to attach it to; Import writes the plays as watch history
// (services/netflix_import). Profiles left on "skip" are not imported.
import { EL, EVENT, _errMsg, act, actOn, esc } from './ui.js';
import { api } from './api.js';
import { state } from './state.js';
import { dropzoneLeave } from './spotify_import.js';

let _users = [];

export function netflixDropZone() {
return `
<div class="dropzone" id="adm-nf-drop"
${actOn('dragover', 'dropzoneOver', EVENT, EL)}
${actOn('dragleave', 'dropzoneLeave', EL)}
${actOn('drop', 'handleNetflixDrop', EVENT)}
${act('openNetflixPicker')}>
<div class="fs-13">Drop your Netflix viewing history here</div>
<div class="mt-4 hint">The data export's zip (or its ViewingActivity.csv), or a profile's NetflixViewingHistory.csv — or click to browse</div>
</div>
<input hidden id="adm-nf-file" type="file" multiple accept=".csv,.zip" ${actOn('change', 'onNetflixFile', EL)}>
<div class="mt-8" id="adm-nf-result"></div>
<div id="adm-nf-pending" class="mt-8"></div>`;
}

export function handleNetflixDrop(ev) {
ev.preventDefault();
dropzoneLeave(ev.currentTarget);
uploadNetflix(ev.dataTransfer.files);
}
export function openNetflixPicker() { document.getElementById('adm-nf-file').click(); }
export function onNetflixFile(el) { uploadNetflix(el.files); }

function _profileRow(name, plays, kind) {
const label = kind === 'simple' ? `${name} (one profile, dates only)` : name;
const opts = ['<option value="">skip</option>',
..._users.map(u => `<option value="${u.id}">${esc(u.plex_username)}</option>`)].join('');
return `<div class="row mt-4">
<span class="fs-12 grow">${esc(label)} <span class="t3">· ${plays} plays</span></span>
<select class="input minw-160" data-nf-profile="${esc(name)}" aria-label="Household member for ${esc(name)}">${opts}</select>
</div>`;
}

export function _netflixPendingHtml(d) {
const files = d?.pending || [];
if (!files.length) return '';
const rows = [];
for (const f of files) {
for (const [name, plays] of Object.entries(f.profiles || {})) rows.push(_profileRow(name, plays, f.kind));
}
const dropped = files.reduce((n, f) => n + Object.values(f.dropped || {}).reduce((a, b) => a + b, 0), 0);
return `<div class="hint">${files.length} file(s) waiting${dropped ? ` · ${dropped} trailers, previews and short starts left out` : ''}. Attach each profile to a household member:</div>
${rows.join('')}
<div class="row mt-8">
<button type="button" class="btn btn-primary btn-sm" ${act('runNetflixImport')}>Import</button>
<button type="button" class="btn btn-secondary btn-sm" ${act('clearNetflixFiles')}>Remove the files</button>
</div>`;
}

export async function refreshNetflixPending(users) {
if (users) _users = users;
const el = document.getElementById('adm-nf-pending');
if (!el) return;
try {
el.innerHTML = _netflixPendingHtml(await api('/api/import/netflix/status'));
} catch (e) {
el.innerHTML = `<div class="test-status err">Couldn't check for uploaded files — ${esc(_errMsg(e))}</div>`;
}
}

export async function uploadNetflix(files) {
if (!files || !files.length) return;
const el = document.getElementById('adm-nf-result');
if (el) el.innerHTML = '<div class="test-status">Uploading…</div>';
const fd = new FormData();
for (const f of files) fd.append('files', f);
// a multipart upload: api() sends JSON
const headers = state.token ? {Authorization: 'Bearer ' + state.token} : {};
try {
const r = await fetch('/api/import/netflix/files', {method: 'POST', body: fd, headers});
const d = await r.json();
if (!r.ok) throw new Error(d.detail || 'upload failed');
if (el) el.innerHTML = [
d.saved.length ? `<div class="test-status ok">${d.saved.length} file(s) accepted</div>` : '',
...d.rejected.map(x => `<div class="test-status err">${esc(x.name)}: ${esc(x.reason)}</div>`),
].join('');
} catch (e) {
if (el) el.innerHTML = `<div class="test-status err">${esc(_errMsg(e))}</div>`;
}
refreshNetflixPending();
}

export function _netflixAssign(selects) {
const out = {};
for (const s of selects) {
const uid = parseInt(s.value);
if (uid) out[s.dataset.nfProfile] = uid;
}
return out;
}

export async function runNetflixImport() {
const el = document.getElementById('adm-nf-result');
const assign = _netflixAssign(document.querySelectorAll('[data-nf-profile]'));
if (!Object.keys(assign).length) {
if (el) el.innerHTML = '<div class="test-status err">Attach at least one profile to a household member first.</div>';
return;
}
try {
await api('/api/import/netflix/run', 'POST', {assign});
if (el) el.innerHTML = '<div class="test-status ok">Import started — watch the Activity view. Titles in the library are matched as the plays go in.</div>';
} catch (e) {
if (el) el.innerHTML = `<div class="test-status err">${esc(_errMsg(e))}</div>`;
}
refreshNetflixPending();
}

export async function clearNetflixFiles() {
try { await api('/api/import/netflix/files', 'DELETE'); } catch (e) { /* the list below says what is left */ }
refreshNetflixPending();
}
6 changes: 5 additions & 1 deletion scripts/dedupe_watch_history.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ def main() -> int:
args = ap.parse_args()

with get_db_session() as db:
q = db.query(WatchHistoryEntry)
# Plex's own rows only: a Netflix play next to a Plex play of the
# same title is two viewings, not a resume or a timezone twin
from sqlalchemy import func
q = db.query(WatchHistoryEntry).filter(
func.coalesce(WatchHistoryEntry.source, "") != "netflix")
if not args.include_music:
q = q.filter(WatchHistoryEntry.media_type.notin_(
("track", "music", "artist", "album")))
Expand Down
3 changes: 3 additions & 0 deletions src/database/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,9 @@ def _migrate_columns() -> None:
# Plays of media gone from Plex are marked, not deleted
# (database/orphaned).
("watch_history", "orphaned_at", "DATETIME"),
# Netflix plays (services/netflix_import).
("watch_history", "source_ref", "VARCHAR(64)"),
("watch_history", "date_only", "BOOLEAN"),
]
# Indexes that need to exist on top of the new columns. ALTER TABLE
# ADD COLUMN doesn't pick up the ``index=True`` flag from the model
Expand Down
7 changes: 6 additions & 1 deletion src/database/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ class WatchHistoryEntry(Base):
tmdb_id = Column(Integer, nullable=True)
# Music / Spotify
spotify_uri = Column(String(100), nullable=True) # full spotify:track:xxx URI
source = Column(String(16), nullable=True) # "plex" | "spotify" | "manual"
source = Column(String(16), nullable=True) # NULL (Plex) | "spotify" | "marked" | "netflix"
# Pass 16f: pre-resolved MusicBrainz artist ID — populated by Phase 1.4
# (resolve_artist_mbids) so the Spotify-Backlog → Lidarr add flow has
# the MBID immediately, no live lookup at click time.
Expand All @@ -115,6 +115,11 @@ class WatchHistoryEntry(Base):
# record; every ORM read hides it unless the session asks for it
# (database/orphaned).
orphaned_at = Column(DateTime, nullable=True)
# Netflix plays (2026-10-25, services/netflix_import): the title's own
# id there (stable while the matcher rewrites plex_item_id), and True
# when the export gave the day only - no clock time, no duration.
source_ref = Column(String(64), nullable=True)
date_only = Column(Boolean, nullable=True)

__table_args__ = (
Index("idx_wh_user_item", "user_id", "plex_item_id"),
Expand Down
88 changes: 87 additions & 1 deletion src/routers/imports.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Data imports through the GUI — no scripts, no shell.
"""Data imports through the GUI — no scripts, no shell: Spotify listening
history and Netflix viewing history.

The upload half is open during first-run setup as well (the wizard's Import
step), because that is exactly when people have their Spotify export at hand.
Expand Down Expand Up @@ -116,3 +117,88 @@ async def _job():
# collected mid-run and vanish without a result or an error.
track_task(_job(), name="spotify_import")
return {"started": True}


# ── Netflix (services/netflix_import) ───────────────────────────────────────
# The same shape: upload (also during setup), then an admin run that says
# whose plays each profile's are.
_nf_running: dict = {}
_nf_last: dict = {}


@router.get("/netflix/status")
def netflix_status(_gate=Depends(require_admin_or_first_run)):
from src.services import netflix_import
return {"pending": netflix_import.pending_files(), "running": bool(_nf_running),
"last_result": _nf_last or None}


@router.post("/netflix/files")
async def netflix_upload(files: list[UploadFile] = File(...),
_gate=Depends(require_admin_or_first_run)):
from src.services import netflix_import
if len(files) > _MAX_FILES:
raise HTTPException(status_code=413, detail=f"At most {_MAX_FILES} files per upload")
saved, rejected = [], []
cap = _MAX_FILE_MB * 1024 * 1024
for up in files:
content = await up.read(cap + 1)
if len(content) > cap:
rejected.append((up.filename, f"larger than {_MAX_FILE_MB} MB"))
continue
result = await asyncio.to_thread(netflix_import.save_upload, up.filename or "upload", content)
saved += result["saved"]
rejected += result["rejected"]
return {"saved": saved,
"rejected": [{"name": n, "reason": r} for n, r in rejected],
"pending": await asyncio.to_thread(netflix_import.pending_files)}


@router.delete("/netflix/files")
def netflix_clear(_admin=Depends(require_admin)):
from src.services import netflix_import
return {"removed": netflix_import.clear_pending()}


@router.post("/netflix/run")
async def netflix_run(payload: dict, _admin=Depends(require_admin)):
"""``{"assign": {profile: user_id}}``: a full export's profile names, a
simple export's file name. Profiles left out are not imported."""
from src.services import netflix_import
if _nf_running:
raise HTTPException(409, "an import is already running")
assign = payload.get("assign")
if not isinstance(assign, dict) or not assign or not all(
isinstance(k, str) and isinstance(v, int) for k, v in assign.items()):
raise HTTPException(422, "assign ({profile: user_id}) is required")
if not await asyncio.to_thread(netflix_import.pending_files):
raise HTTPException(400, "no pending files — upload first")
if _nf_running:
raise HTTPException(409, "an import is already running")

from src.services.task_monitor import task_monitor
card = task_monitor.create(name="Netflix history import", category="import", task_id="netflix-import")
task_monitor.start(card)
_nf_running["task"] = True

async def _job():
try:
stats = await netflix_import.run_import(assign, task=card)
_nf_last.clear()
_nf_last.update(stats)
if stats.get("error"):
task_monitor.error(card, stats["error"])
else:
task_monitor.done(card, f"{stats['imported']} plays imported "
f"({stats['plays_in_library']} of {stats['plays']} on library titles), "
f"{stats['duplicates']} duplicates skipped")
except Exception as e:
logger.warning("[netflix-import] failed: %s", e)
_nf_last.clear()
_nf_last.update({"error": str(e)})
task_monitor.error(card, str(e))
finally:
_nf_running.clear()

track_task(_job(), name="netflix_import")
return {"started": True}
9 changes: 5 additions & 4 deletions src/services/app_context.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,10 +124,11 @@
"EVERY background job — syncs, maintenance, memory extraction, LLM "
"runs — with progress and history) · 'Settings' (admin panes: 'Plex "
"libraries' maps each Plex library to a category, 'Arr apps' holds the "
"Sonarr/Radarr/Lidarr connections, 'Users' includes the Spotify history "
"import, 'Maintenance' has sync and repair actions and 'Compute nodes': "
"this server and the node that lends its card, which the owner can "
"rename and update there).\n"
"Sonarr/Radarr/Lidarr connections, 'Users' includes the Spotify and "
"Netflix history imports (a member's Netflix viewing counts like their "
"Plex viewing), 'Maintenance' has sync and repair actions and 'Compute "
"nodes': this server and the node that lends its card, with what each "
"card is doing right now, which the owner can rename and update there).\n"
)

# ── Knowledge Base health: why the library is not at 100 % ───────────────────
Expand Down
Loading
Loading