Kosala Nayanajith Deshapriya commited on
Commit
4dc7a21
·
0 Parent(s):

Ad Placement Recommender - clean deploy

Browse files
.env.example ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ YOUTUBE_API_KEY=your_api_key_here
2
+ GOOGLE_CLIENT_SECRET_FILE=client_secret.json
3
+ CHANNEL_ID=your_channel_id_here
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.mp4 filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # credentials & secrets
2
+ client_secrets.json
3
+ credentials.json
4
+ token.json
5
+ *.pickle
6
+ .env
7
+
8
+ # python
9
+ .venv/
10
+ __pycache__/
11
+ *.pyc
12
+ *.pyo
13
+
14
+ # data files (too large for github)
15
+ test_video/
16
+ *.mp4
17
+
18
+ # generated outputs
19
+ features.csv
20
+ ranked_candidates.json
21
+ final_recommendations.json
22
+ retention_curve.json
23
+ shap_importance.png
24
+
25
+ # streamlit
26
+ .streamlit/secrets.toml
27
+ client_secret.json
README.md ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Retention-Aware Mid-Roll Ad Placement System
2
+
3
+ ## Project Structure
4
+ ```
5
+ project/
6
+ ├── requirements.txt
7
+ ├── .env.example
8
+ ├── simulate_and_test.py ← Start here (no video needed)
9
+ ├── component1_candidate_generator.py
10
+ ├── component2_feature_extractor.py
11
+ ├── candidates.json ← Output of Component 1
12
+ ├── features.csv ← Output of Component 2
13
+ └── README.md
14
+ ```
15
+
16
+ ## Quick Start
17
+ ```bash
18
+ # 1. Install dependencies
19
+ pip install -r requirements.txt
20
+
21
+ # 2. Test the pipeline (no video or API needed)
22
+ python simulate_and_test.py
23
+ python component2_feature_extractor.py
24
+
25
+ # 3. Run on a real video
26
+ python component1_candidate_generator.py your_video.mp4
27
+ python component2_feature_extractor.py
28
+ ```
29
+
30
+ ## Phases
31
+ - [x] Phase 1 — Environment Setup
32
+ - [x] Phase 2 — Component 1: Candidate Generator
33
+ - [x] Phase 3 — Component 2: Feature Extractor (simulated retention)
34
+ - [ ] Phase 4 — Component 3: ML Ranker (LightGBM)
35
+ - [ ] Phase 5 — Component 4: Ad Placement Recommender
36
+ - [ ] Phase 6 — FastAPI + Dashboard
37
+ - [ ] Phase 7 — Evaluation + Research Paper
candidates.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "total_duration": 454.26666666666665,
3
+ "candidates": [
4
+ {
5
+ "timestamp": 113.87,
6
+ "type": "scene_change",
7
+ "score": 92.046
8
+ },
9
+ {
10
+ "timestamp": 186.33,
11
+ "type": "scene_change",
12
+ "score": 32.416
13
+ },
14
+ {
15
+ "timestamp": 258.68,
16
+ "type": "silence",
17
+ "score": 1.65
18
+ },
19
+ {
20
+ "timestamp": 330.9,
21
+ "type": "scene_change",
22
+ "score": 95.956
23
+ },
24
+ {
25
+ "timestamp": 392.02,
26
+ "type": "silence",
27
+ "score": 7.15
28
+ }
29
+ ]
30
+ }
component1_candidate_generator.py ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import librosa
2
+ import cv2
3
+ import numpy as np
4
+ import whisper
5
+ import json
6
+ from pathlib import Path
7
+
8
+ SILENCE_THRESHOLD = 0.01
9
+ SILENCE_MIN_DURATION = 1.5 # seconds
10
+ SCENE_THRESHOLD = 30.0 # frame diff threshold
11
+ MIN_GAP_SECONDS = 60 # min gap between candidates
12
+
13
+ def detect_silence(audio_path):
14
+ y, sr = librosa.load(audio_path, sr=None, mono=True)
15
+ frame_length = int(sr * 0.1)
16
+ hop_length = frame_length // 2
17
+ rms = librosa.feature.rms(y=y, frame_length=frame_length, hop_length=hop_length)[0]
18
+ times = librosa.frames_to_time(np.arange(len(rms)), sr=sr, hop_length=hop_length)
19
+ candidates = []
20
+ in_silence = False
21
+ silence_start = 0
22
+ for t, r in zip(times, rms):
23
+ if r < SILENCE_THRESHOLD and not in_silence:
24
+ in_silence = True
25
+ silence_start = t
26
+ elif r >= SILENCE_THRESHOLD and in_silence:
27
+ duration = t - silence_start
28
+ if duration >= SILENCE_MIN_DURATION:
29
+ candidates.append({"timestamp": round(silence_start + duration / 2, 2),
30
+ "type": "silence", "score": round(float(duration), 3)})
31
+ in_silence = False
32
+ return candidates
33
+
34
+ def detect_scene_changes(video_path):
35
+ cap = cv2.VideoCapture(video_path)
36
+ fps = cap.get(cv2.CAP_PROP_FPS)
37
+ candidates = []
38
+ prev_frame = None
39
+ frame_idx = 0
40
+ while True:
41
+ ret, frame = cap.read()
42
+ if not ret:
43
+ break
44
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
45
+ if prev_frame is not None:
46
+ diff = np.mean(np.abs(gray.astype(float) - prev_frame.astype(float)))
47
+ if diff > SCENE_THRESHOLD:
48
+ t = round(frame_idx / fps, 2)
49
+ candidates.append({"timestamp": t, "type": "scene_change", "score": round(float(diff), 3)})
50
+ prev_frame = gray
51
+ frame_idx += 1
52
+ cap.release()
53
+ return candidates
54
+
55
+ def detect_transcript_boundaries(audio_path):
56
+ model = whisper.load_model("base")
57
+ result = model.transcribe(audio_path, word_timestamps=True)
58
+ candidates = []
59
+ segments = result.get("segments", [])
60
+ for i in range(1, len(segments)):
61
+ gap = segments[i]["start"] - segments[i-1]["end"]
62
+ if gap > 1.0:
63
+ t = round((segments[i-1]["end"] + segments[i]["start"]) / 2, 2)
64
+ candidates.append({"timestamp": t, "type": "transcript_boundary", "score": round(gap, 3)})
65
+ return candidates
66
+
67
+ def merge_candidates(all_candidates, total_duration, min_gap=MIN_GAP_SECONDS):
68
+ all_candidates.sort(key=lambda x: x["timestamp"])
69
+ # Remove candidates in first 20% and last 10%
70
+ start_cut = total_duration * 0.20
71
+ end_cut = total_duration * 0.90
72
+ filtered = [c for c in all_candidates if start_cut <= c["timestamp"] <= end_cut]
73
+ merged = []
74
+ last_t = -min_gap
75
+ for c in filtered:
76
+ if c["timestamp"] - last_t >= min_gap:
77
+ merged.append(c)
78
+ last_t = c["timestamp"]
79
+ return merged
80
+
81
+ def run(video_path, output_path="candidates.json"):
82
+ print(f"[Component 1] Processing: {video_path}")
83
+ cap = cv2.VideoCapture(video_path)
84
+ fps = cap.get(cv2.CAP_PROP_FPS)
85
+ frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
86
+ total_duration = frame_count / fps
87
+ cap.release()
88
+ silence = detect_silence(video_path)
89
+ print(f" Silence candidates: {len(silence)}")
90
+ scene = detect_scene_changes(video_path)
91
+ print(f" Scene change candidates: {len(scene)}")
92
+ transcript = detect_transcript_boundaries(video_path)
93
+ print(f" Transcript boundary candidates: {len(transcript)}")
94
+ all_c = silence + scene + transcript
95
+ merged = merge_candidates(all_c, total_duration)
96
+ print(f" Final merged candidates: {len(merged)}")
97
+ with open(output_path, "w") as f:
98
+ json.dump({"total_duration": total_duration, "candidates": merged}, f, indent=2)
99
+ print(f" Saved to {output_path}")
100
+ return merged
101
+
102
+ if __name__ == "__main__":
103
+ import sys
104
+ video_path = sys.argv[1] if len(sys.argv) > 1 else "test_video.mp4"
105
+ run(video_path)
component2_feature_extractor.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ component2_feature_extractor.py — Updated with real YouTube Analytics support
3
+ Simulated : python component2_feature_extractor.py
4
+ Real data : python component2_feature_extractor.py --video-id YOUR_VIDEO_ID
5
+ """
6
+
7
+ import json
8
+ import sys
9
+ import pandas as pd
10
+ import numpy as np
11
+
12
+
13
+ def simulate_retention_curve(total_duration, seed=42):
14
+ np.random.seed(seed)
15
+ t = np.linspace(0, total_duration, int(total_duration))
16
+ base = 100 * np.exp(-0.003 * t)
17
+ noise = np.random.normal(0, 2, len(t))
18
+ spikes = np.zeros(len(t))
19
+ for _ in range(5):
20
+ spike_t = np.random.randint(0, len(t))
21
+ spikes[max(0, spike_t-10):spike_t+10] += np.random.uniform(3, 8)
22
+ return pd.DataFrame({"second": t, "retention_pct": np.clip(base + noise + spikes, 0, 100)})
23
+
24
+
25
+ def get_real_retention_curve(video_id):
26
+ try:
27
+ from youtube_analytics import get_retention_curve
28
+ df = get_retention_curve(video_id)
29
+ return df
30
+ except Exception as e:
31
+ print(f"[Component 2] Warning: {e}")
32
+ print("[Component 2] Falling back to simulated retention curve")
33
+ return None
34
+
35
+
36
+ def get_retention_at(curve_df, t, window=10):
37
+ mask = (curve_df["second"] >= t - window) & (curve_df["second"] <= t + window)
38
+ subset = curve_df[mask]
39
+ if subset.empty:
40
+ return 0.0, 0.0, 0.0
41
+ at_t_idx = (subset["second"] - t).abs().idxmin()
42
+ retention_at_t = curve_df.loc[at_t_idx, "retention_pct"]
43
+ before = curve_df[curve_df["second"] < t].tail(30)
44
+ after = curve_df[curve_df["second"] > t].head(30)
45
+ further = curve_df[curve_df["second"] > t + 30].head(30)
46
+ drop_rate = (before["retention_pct"].mean() - after["retention_pct"].mean()) if len(before) and len(after) else 0
47
+ recovery = after["retention_pct"].mean() - further["retention_pct"].mean() if len(after) and len(further) else 0
48
+ return round(float(retention_at_t), 3), round(float(drop_rate), 3), round(float(recovery), 3)
49
+
50
+
51
+ def extract_features(candidates_path="candidates.json", video_id=None):
52
+ with open(candidates_path) as f:
53
+ data = json.load(f)
54
+
55
+ candidates = data["candidates"]
56
+ total_duration = data["total_duration"]
57
+
58
+ if video_id:
59
+ print(f"[Component 2] Fetching REAL retention for video: {video_id}")
60
+ curve_df = get_real_retention_curve(video_id)
61
+ if curve_df is None:
62
+ curve_df = simulate_retention_curve(total_duration)
63
+ else:
64
+ print("[Component 2] Using SIMULATED retention curve")
65
+ curve_df = simulate_retention_curve(total_duration)
66
+
67
+ rows = []
68
+ for i, c in enumerate(candidates):
69
+ t = c["timestamp"]
70
+ ret_at_t, drop_rate, recovery = get_retention_at(curve_df, t)
71
+ time_since_last = t - candidates[i-1]["timestamp"] if i > 0 else t
72
+ rows.append({
73
+ "timestamp": t,
74
+ "type": c["type"],
75
+ "content_score": c["score"],
76
+ "retention_at_t": ret_at_t,
77
+ "retention_drop_rate": drop_rate,
78
+ "retention_recovery": recovery,
79
+ "relative_position": round(t / total_duration, 4),
80
+ "time_since_last_candidate": round(time_since_last, 2),
81
+ "label": None
82
+ })
83
+
84
+ df = pd.DataFrame(rows)
85
+ df["label"] = (
86
+ (df["retention_at_t"] > df["retention_at_t"].median()) &
87
+ (df["retention_drop_rate"] < df["retention_drop_rate"].median())
88
+ ).astype(int)
89
+
90
+ df.to_csv("features.csv", index=False)
91
+ print("[Component 2] features.csv saved ✅")
92
+ print(df[["timestamp", "type", "retention_at_t", "retention_drop_rate", "label"]].to_string(index=False))
93
+ return df
94
+
95
+
96
+ if __name__ == "__main__":
97
+ video_id = None
98
+ if "--video-id" in sys.argv:
99
+ idx = sys.argv.index("--video-id")
100
+ video_id = sys.argv[idx + 1]
101
+ extract_features(video_id=video_id)
component3_ml_ranker.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Component 3: ML Ranking Engine
3
+ - Loads features.csv from Component 2
4
+ - Trains a LightGBM model to score each candidate timestamp
5
+ - Outputs ranked_candidates.json with placement scores
6
+ - Generates SHAP feature importance analysis
7
+ """
8
+
9
+ import pandas as pd
10
+ import numpy as np
11
+ import lightgbm as lgb
12
+ import shap
13
+ import json
14
+ import matplotlib
15
+ matplotlib.use("Agg")
16
+ import matplotlib.pyplot as plt
17
+
18
+ FEATURES = [
19
+ "content_score",
20
+ "retention_at_t",
21
+ "retention_drop_rate",
22
+ "retention_recovery",
23
+ "relative_position",
24
+ "time_since_last_candidate",
25
+ ]
26
+
27
+ def load_features(path="features.csv"):
28
+ df = pd.read_csv(path)
29
+ print(f"[Component 3] Loaded {len(df)} candidates from {path}")
30
+ print(df[["timestamp", "type", "retention_at_t", "label"]].to_string(index=False))
31
+ return df
32
+
33
+ def encode_type(df):
34
+ type_map = {"silence": 0, "scene_change": 1, "transcript_boundary": 2}
35
+ df = df.copy()
36
+ df["type_encoded"] = df["type"].map(type_map).fillna(0).astype(int)
37
+ return df
38
+
39
+ def train_model(df):
40
+ df = encode_type(df)
41
+ feature_cols = FEATURES + ["type_encoded"]
42
+ X = df[feature_cols].fillna(0)
43
+ y = df["label"]
44
+
45
+ params = {
46
+ "objective": "binary",
47
+ "metric": "auc",
48
+ "learning_rate": 0.05,
49
+ "num_leaves": 15,
50
+ "min_child_samples": 1,
51
+ "n_estimators": 100,
52
+ "verbose": -1,
53
+ "random_state": 42,
54
+ }
55
+
56
+ n_positive = y.sum()
57
+ n_samples = len(y)
58
+
59
+ if n_samples >= 10 and n_positive >= 3:
60
+ from sklearn.model_selection import StratifiedKFold
61
+ from sklearn.metrics import roc_auc_score
62
+ skf = StratifiedKFold(n_splits=3, shuffle=True, random_state=42)
63
+ auc_scores = []
64
+ for fold, (train_idx, val_idx) in enumerate(skf.split(X, y)):
65
+ X_train, X_val = X.iloc[train_idx], X.iloc[val_idx]
66
+ y_train, y_val = y.iloc[train_idx], y.iloc[val_idx]
67
+ m = lgb.LGBMClassifier(**params)
68
+ m.fit(X_train, y_train, callbacks=[lgb.log_evaluation(period=-1)])
69
+ preds = m.predict_proba(X_val)[:, 1]
70
+ auc = roc_auc_score(y_val, preds)
71
+ auc_scores.append(auc)
72
+ print(f" Fold {fold+1} AUC: {auc:.4f}")
73
+ print(f" Mean CV AUC: {np.mean(auc_scores):.4f}")
74
+ else:
75
+ print(f" [Note] Small dataset ({n_samples} samples, {n_positive} positive)")
76
+ print(f" Skipping CV — training directly on all data")
77
+ print(f" (CV requires 10+ samples with 3+ positive labels)")
78
+
79
+ final_model = lgb.LGBMClassifier(**params)
80
+ final_model.fit(X, y, callbacks=[lgb.log_evaluation(period=-1)])
81
+ print(" Final model trained ✅")
82
+ return final_model, feature_cols
83
+
84
+ def score_candidates(df, model, feature_cols):
85
+ df = encode_type(df)
86
+ X = df[feature_cols].fillna(0)
87
+ df = df.copy()
88
+ df["placement_score"] = model.predict_proba(X)[:, 1]
89
+ df_sorted = df.sort_values("placement_score", ascending=False).reset_index(drop=True)
90
+ df_sorted["rank"] = df_sorted.index + 1
91
+ mins = (df_sorted["timestamp"] // 60).astype(int)
92
+ secs = (df_sorted["timestamp"] % 60).astype(int)
93
+ df_sorted["timestamp_formatted"] = mins.astype(str) + "m " + secs.astype(str) + "s"
94
+ return df_sorted
95
+
96
+ def save_shap_plot(model, df, feature_cols, output_path="shap_importance.png"):
97
+ df = encode_type(df)
98
+ X = df[feature_cols].fillna(0)
99
+ explainer = shap.TreeExplainer(model)
100
+ shap_values = explainer.shap_values(X)
101
+ vals = shap_values[1] if isinstance(shap_values, list) else shap_values
102
+ mean_shap = np.abs(vals).mean(axis=0)
103
+ feat_imp = pd.Series(mean_shap, index=feature_cols).sort_values(ascending=True)
104
+ fig, ax = plt.subplots(figsize=(8, 5))
105
+ feat_imp.plot(kind="barh", ax=ax, color="#6C63FF")
106
+ ax.set_title("Feature Importance (SHAP)", fontsize=13, fontweight="bold")
107
+ ax.set_xlabel("Mean |SHAP value|")
108
+ plt.tight_layout()
109
+ plt.savefig(output_path, dpi=150)
110
+ plt.close()
111
+ print(f" SHAP plot saved → {output_path}")
112
+
113
+ def save_results(df_ranked, output_path="ranked_candidates.json"):
114
+ results = []
115
+ for _, row in df_ranked.iterrows():
116
+ results.append({
117
+ "rank": int(row["rank"]),
118
+ "timestamp": float(row["timestamp"]),
119
+ "timestamp_formatted": row["timestamp_formatted"],
120
+ "type": row["type"],
121
+ "placement_score": round(float(row["placement_score"]), 4),
122
+ "retention_at_t": round(float(row["retention_at_t"]), 2),
123
+ "label": int(row["label"])
124
+ })
125
+ with open(output_path, "w") as f:
126
+ json.dump({"total_candidates": len(results), "ranked_placements": results}, f, indent=2)
127
+ print(f" Results saved → {output_path}")
128
+
129
+ def run(features_path="features.csv"):
130
+ print("=" * 55)
131
+ print(" COMPONENT 3: ML RANKING ENGINE")
132
+ print("=" * 55)
133
+ df = load_features(features_path)
134
+ print("\n[Training LightGBM Ranker...]")
135
+ model, feature_cols = train_model(df)
136
+ print("\n[Scoring All Candidates...]")
137
+ df_ranked = score_candidates(df, model, feature_cols)
138
+ print("\n[Ranked Placement Timestamps]")
139
+ print(df_ranked[["rank", "timestamp_formatted", "type",
140
+ "placement_score", "retention_at_t", "label"]].to_string(index=False))
141
+ print("\n[Generating SHAP Feature Importance...]")
142
+ save_shap_plot(model, df, feature_cols)
143
+ save_results(df_ranked)
144
+ print("\n✅ Component 3 Complete!")
145
+ print(" → ranked_candidates.json")
146
+ print(" → shap_importance.png")
147
+ print(" Next: python component4_recommender.py")
148
+
149
+ if __name__ == "__main__":
150
+ run()
component4_recommender.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Component 4: Smart Recommender
3
+ - Loads ranked_candidates.json from Component 3
4
+ - Applies business rules to filter and finalize ad placement timestamps
5
+ - Outputs final_recommendations.json — the creator-ready result
6
+ """
7
+
8
+ import json
9
+ import os
10
+
11
+ # ── Business Rule Config (tweak these per creator/video) ──
12
+ CONFIG = {
13
+ "min_placement_score": 0.0, # minimum ML score to consider
14
+ "min_gap_seconds": 120, # minimum 3 min between placements
15
+ "skip_intro_pct": 0.20, # skip first 20% of video
16
+ "skip_outro_pct": 0.10, # skip last 10% of video
17
+ "max_placements": 3, # max sponsored segments per video
18
+ "short_video_threshold": 480, # videos <= 8 min → max 1 placement
19
+ "post_peak_bonus": 0.05, # future: boost score if after sentiment peak
20
+ }
21
+
22
+ def load_ranked(path="ranked_candidates.json"):
23
+ with open(path) as f:
24
+ data = json.load(f)
25
+ placements = data["ranked_placements"]
26
+ # find total duration from highest timestamp as fallback
27
+ total_duration = max(p["timestamp"] for p in placements) / 0.80
28
+ print(f"[Component 4] Loaded {len(placements)} ranked candidates")
29
+ print(f" Estimated video duration: {int(total_duration//60)}m {int(total_duration%60)}s")
30
+ return placements, total_duration
31
+
32
+ def apply_rules(placements, total_duration, config=CONFIG):
33
+ print("\n[Applying Business Rules]")
34
+
35
+ # Rule 1: Score threshold
36
+ filtered = [p for p in placements if p["placement_score"] >= config["min_placement_score"]]
37
+ print(f" Rule 1 — Score >= {config['min_placement_score']}: {len(filtered)} passed")
38
+
39
+ # Rule 2: Skip intro
40
+ intro_cut = total_duration * config["skip_intro_pct"]
41
+ filtered = [p for p in filtered if p["timestamp"] >= intro_cut]
42
+ print(f" Rule 2 — Skip intro ({int(config['skip_intro_pct']*100)}%, < {int(intro_cut)}s removed): {len(filtered)} remain")
43
+
44
+ # Rule 3: Skip outro
45
+ outro_cut = total_duration * (1 - config["skip_outro_pct"])
46
+ filtered = [p for p in filtered if p["timestamp"] <= outro_cut]
47
+ print(f" Rule 3 — Skip outro ({int(config['skip_outro_pct']*100)}%, > {int(outro_cut)}s removed): {len(filtered)} remain")
48
+
49
+ # Rule 4: Max placements for short videos
50
+ max_allow = 1 if total_duration <= config["short_video_threshold"] else config["max_placements"]
51
+ print(f" Rule 4 — Max placements allowed: {max_allow}")
52
+
53
+ # Rule 5: Minimum gap enforcement (greedy selection by score)
54
+ selected = []
55
+ for p in sorted(filtered, key=lambda x: x["placement_score"], reverse=True):
56
+ if len(selected) >= max_allow:
57
+ break
58
+ too_close = any(abs(p["timestamp"] - s["timestamp"]) < config["min_gap_seconds"] for s in selected)
59
+ if not too_close:
60
+ selected.append(p)
61
+
62
+ print(f" Rule 5 — Min gap {config['min_gap_seconds']}s enforced: {len(selected)} final placements")
63
+ return selected
64
+
65
+ def format_output(selected, total_duration, config=CONFIG):
66
+ output = {
67
+ "video_duration_seconds": round(total_duration, 1),
68
+ "video_duration_formatted": f"{int(total_duration//60)}m {int(total_duration%60)}s",
69
+ "total_placements_recommended": len(selected),
70
+ "config_used": config,
71
+ "recommendations": []
72
+ }
73
+
74
+ for i, p in enumerate(sorted(selected, key=lambda x: x["timestamp"])):
75
+ output["recommendations"].append({
76
+ "placement_number": i + 1,
77
+ "timestamp_seconds": p["timestamp"],
78
+ "timestamp_formatted": p["timestamp_formatted"],
79
+ "type": p["type"],
80
+ "placement_score": p["placement_score"],
81
+ "retention_at_t": p["retention_at_t"],
82
+ "confidence": (
83
+ "HIGH" if p["placement_score"] >= 0.75 else
84
+ "MEDIUM" if p["placement_score"] >= 0.50 else
85
+ "LOW"
86
+ ),
87
+ "creator_note": (
88
+ f"Place sponsored segment at {p['timestamp_formatted']} — "
89
+ f"natural {p['type'].replace('_', ' ')} detected, "
90
+ f"{p['retention_at_t']:.1f}% viewers still watching."
91
+ )
92
+ })
93
+ return output
94
+
95
+ def print_summary(output):
96
+ print("\n" + "=" * 55)
97
+ print(" FINAL RECOMMENDATIONS FOR CREATOR")
98
+ print("=" * 55)
99
+ print(f" Video Duration : {output['video_duration_formatted']}")
100
+ print(f" Placements : {output['total_placements_recommended']}")
101
+ print()
102
+ if not output["recommendations"]:
103
+ print(" ⚠️ No suitable placement found.")
104
+ print(" Suggestion: Lower min_placement_score in CONFIG")
105
+ print(" or collect more video data for better ML labels.")
106
+ else:
107
+ for r in output["recommendations"]:
108
+ print(f" 📍 Placement {r['placement_number']}")
109
+ print(f" Timestamp : {r['timestamp_formatted']}")
110
+ print(f" Type : {r['type']}")
111
+ print(f" Score : {r['placement_score']} ({r['confidence']})")
112
+ print(f" Retention : {r['retention_at_t']}% viewers watching")
113
+ print(f" Note : {r['creator_note']}")
114
+ print()
115
+
116
+ def run(ranked_path="ranked_candidates.json", output_path="final_recommendations.json"):
117
+ print("=" * 55)
118
+ print(" COMPONENT 4: SMART RECOMMENDER")
119
+ print("=" * 55)
120
+ placements, total_duration = load_ranked(ranked_path)
121
+ selected = apply_rules(placements, total_duration)
122
+ output = format_output(selected, total_duration)
123
+ print_summary(output)
124
+ with open(output_path, "w") as f:
125
+ json.dump(output, f, indent=2)
126
+ print(f"✅ Component 4 Complete!")
127
+ print(f" → {output_path}")
128
+ print(f" Next: python dashboard.py (Streamlit visualization)")
129
+
130
+ if __name__ == "__main__":
131
+ run()
dashboard.py ADDED
@@ -0,0 +1,437 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dashboard — Creator-Friendly Ad Placement Recommender
3
+ Full OAuth login + integrated pipeline (no subprocess)
4
+ """
5
+
6
+ import streamlit as st
7
+ import json
8
+ import os
9
+ import tempfile
10
+ import pandas as pd
11
+ import plotly.graph_objects as go
12
+
13
+ # ── Page Config ──
14
+ st.set_page_config(
15
+ page_title="Ad Placement Recommender",
16
+ page_icon="🎯",
17
+ layout="wide"
18
+ )
19
+
20
+ # ── Custom CSS ──
21
+ st.markdown("""
22
+ <style>
23
+ .main { background-color: #0e1117; }
24
+ .big-title { font-size:2.2rem; font-weight:800; color:#ffffff; }
25
+ .subtitle { font-size:1rem; color:#aaaaaa; margin-bottom:2rem; }
26
+ .metric-box {
27
+ background:#1c1f26; border-radius:12px;
28
+ padding:1.2rem; text-align:center;
29
+ }
30
+ .metric-label { font-size:0.85rem; color:#888; margin-bottom:4px; }
31
+ .metric-value { font-size:2rem; font-weight:700; color:#ffffff; }
32
+ .placement-card {
33
+ background:#1a2a1a; border:1px solid #2a5a2a;
34
+ border-radius:12px; padding:1.4rem; margin-bottom:1rem;
35
+ }
36
+ .placement-card-warn {
37
+ background:#2a2a1a; border:1px solid #5a5a2a;
38
+ border-radius:12px; padding:1.4rem; margin-bottom:1rem;
39
+ }
40
+ .placement-card-bad {
41
+ background:#2a1a1a; border:1px solid #5a2a2a;
42
+ border-radius:12px; padding:1.4rem; margin-bottom:1rem;
43
+ }
44
+ .tip-box {
45
+ background:#111827; border-left:4px solid #3b82f6;
46
+ border-radius:8px; padding:1rem; margin-top:0.5rem;
47
+ font-size:0.9rem; color:#cbd5e1;
48
+ }
49
+ .section-header {
50
+ font-size:1.3rem; font-weight:700;
51
+ color:#ffffff; margin:2rem 0 1rem 0;
52
+ }
53
+ .badge-green { background:#166534; color:#86efac; padding:3px 10px; border-radius:20px; font-size:0.8rem; }
54
+ .badge-yellow { background:#713f12; color:#fde68a; padding:3px 10px; border-radius:20px; font-size:0.8rem; }
55
+ .badge-red { background:#7f1d1d; color:#fca5a5; padding:3px 10px; border-radius:20px; font-size:0.8rem; }
56
+ .stButton>button {
57
+ background:linear-gradient(135deg,#3b82f6,#8b5cf6);
58
+ color:white; border:none; border-radius:10px;
59
+ padding:0.7rem 2rem; font-size:1rem; font-weight:600;
60
+ width:100%; cursor:pointer;
61
+ }
62
+ .login-card {
63
+ background:#1c1f26; border-radius:16px;
64
+ padding:2.5rem; text-align:center; max-width:480px; margin:auto;
65
+ }
66
+ </style>
67
+ """, unsafe_allow_html=True)
68
+
69
+
70
+ # ── Helpers ──
71
+
72
+ def get_spot_quality(retention):
73
+ if retention >= 50:
74
+ return "🟢", "Great Spot", "placement-card", "badge-green"
75
+ elif retention >= 30:
76
+ return "🟡", "Decent Spot", "placement-card-warn", "badge-yellow"
77
+ else:
78
+ return "🔴", "Weak Spot", "placement-card-bad", "badge-red"
79
+
80
+ def get_type_label(t):
81
+ return {
82
+ "scene_change": "🎬 Natural scene break",
83
+ "silence": "🔇 Quiet moment",
84
+ "transcript_boundary": "🗣️ Topic change"
85
+ }.get(t, t)
86
+
87
+ def get_type_tip(t):
88
+ return {
89
+ "scene_change": "The video visually transitions here — viewers naturally expect a brief pause. Perfect for a sponsorship read.",
90
+ "silence": "There's a quiet gap in audio here — inserting an ad won't feel jarring or cut off speech.",
91
+ "transcript_boundary": "The speaker shifts to a new topic — a natural mental break for the viewer."
92
+ }.get(t, "Natural break detected in the video.")
93
+
94
+ def extract_video_id(url):
95
+ import re
96
+ for pattern in [r"youtu\.be/([^?&]+)", r"youtube\.com/watch\?v=([^&]+)"]:
97
+ m = re.search(pattern, url)
98
+ if m:
99
+ return m.group(1)
100
+ if re.match(r'^[A-Za-z0-9_-]{11}$', url.strip()):
101
+ return url.strip()
102
+ return None
103
+
104
+ def load_json(path):
105
+ if not os.path.exists(path):
106
+ return None
107
+ with open(path) as f:
108
+ return json.load(f)
109
+
110
+
111
+ # ══════════════════════════════════════════════
112
+ # HEADER
113
+ # ══════════════════════════════════════════════
114
+ st.markdown('<div class="big-title">🎯 Ad Placement Recommender</div>', unsafe_allow_html=True)
115
+ st.markdown('<div class="subtitle">Find the perfect moments in your video to place ads — so viewers stay happy and you earn more.</div>', unsafe_allow_html=True)
116
+
117
+
118
+ # ══════════════════════════════════════════════
119
+ # AUTH GATE
120
+ # ══════════════════════════════════════════════
121
+ from youtube_auth import get_credentials, show_login_button, logout
122
+
123
+ creds = get_credentials()
124
+
125
+ if creds is None:
126
+ st.markdown("---")
127
+ st.markdown("""
128
+ <div class="login-card">
129
+ <div style="font-size:3rem;">🔐</div>
130
+ <div style="font-size:1.3rem; font-weight:700; color:#fff; margin:1rem 0;">
131
+ Connect Your YouTube Account
132
+ </div>
133
+ <div style="color:#9ca3af; font-size:0.9rem; margin-bottom:1.5rem;">
134
+ We need read-only access to your YouTube Analytics<br>
135
+ to see where viewers drop off in your videos.<br><br>
136
+ <b style="color:#6ee7b7;">We never post, modify or delete anything.</b>
137
+ </div>
138
+ </div>
139
+ """, unsafe_allow_html=True)
140
+
141
+ auth_url = show_login_button()
142
+ col1, col2, col3 = st.columns([1, 2, 1])
143
+ with col2:
144
+ st.markdown(f"""
145
+ <a href="{auth_url}" target="_self" style="text-decoration:none;">
146
+ <div style="
147
+ background:linear-gradient(135deg,#ff0000,#cc0000);
148
+ color:white; border-radius:12px; padding:1rem 2rem;
149
+ font-size:1.1rem; font-weight:700; text-align:center;
150
+ margin-top:1.5rem; cursor:pointer;
151
+ ">
152
+ 🎬 Login with YouTube
153
+ </div>
154
+ </a>
155
+ """, unsafe_allow_html=True)
156
+ st.stop()
157
+
158
+ # ── Logged in — show logout in sidebar ──
159
+ with st.sidebar:
160
+ st.markdown("### 👤 Account")
161
+ st.success("✅ YouTube Connected")
162
+ if st.button("🚪 Logout"):
163
+ logout()
164
+ st.rerun()
165
+ st.markdown("---")
166
+ st.markdown("### ℹ️ How It Works")
167
+ st.markdown("""
168
+ 1. 🔗 Paste your YouTube video URL
169
+ 2. 🎬 Upload the same video as `.mp4`
170
+ 3. 🚀 Click Analyze
171
+ 4. 📍 See exactly where to place ads
172
+ """)
173
+
174
+
175
+ # ══════════════════════════════════════════════
176
+ # INPUT FORM
177
+ # ══════════════════════════════════════════════
178
+ st.markdown('<div class="section-header">📥 Analyze Your Video</div>', unsafe_allow_html=True)
179
+
180
+ col1, col2 = st.columns([1, 1])
181
+ with col1:
182
+ yt_input = st.text_input(
183
+ "🔗 YouTube Video URL",
184
+ placeholder="https://youtu.be/4Rq-LY16WxM",
185
+ help="Must be YOUR video — we need access to its analytics"
186
+ )
187
+ with col2:
188
+ uploaded_file = st.file_uploader(
189
+ "🎬 Upload Your Video File (.mp4)",
190
+ type=["mp4"],
191
+ help="Upload the same video you posted on YouTube"
192
+ )
193
+
194
+ analyze_btn = st.button("🚀 Analyze My Video & Find Best Ad Spots")
195
+
196
+ if analyze_btn:
197
+ if not yt_input or not uploaded_file:
198
+ st.warning("⚠️ Please provide both your YouTube URL and upload your video file.")
199
+ else:
200
+ video_id = extract_video_id(yt_input)
201
+ if not video_id:
202
+ st.error("❌ Could not extract video ID. Please check your YouTube URL.")
203
+ else:
204
+ # Save uploaded video to temp file
205
+ os.makedirs("test_video", exist_ok=True)
206
+ save_path = os.path.join("test_video", uploaded_file.name)
207
+ with open(save_path, "wb") as f:
208
+ f.write(uploaded_file.getbuffer())
209
+
210
+ # Run pipeline with live progress
211
+ from pipeline import run_full_pipeline
212
+
213
+ progress_box = st.empty()
214
+ progress_bar = st.progress(0)
215
+ steps_done = [0]
216
+
217
+ def on_progress(msg):
218
+ progress_box.info(msg)
219
+ steps_done[0] += 1
220
+ progress_bar.progress(min(steps_done[0] / 4, 1.0))
221
+
222
+ try:
223
+ with st.spinner("Analyzing your video... this takes 2–4 minutes ⏳"):
224
+ run_full_pipeline(save_path, video_id, creds, progress_callback=on_progress)
225
+ progress_bar.progress(1.0)
226
+ progress_box.success("✅ Analysis complete!")
227
+ st.rerun()
228
+ except Exception as e:
229
+ st.error(f"❌ Pipeline error: {str(e)}")
230
+ st.info("💡 Make sure your video is public or unlisted and belongs to your connected YouTube channel.")
231
+
232
+ st.markdown("---")
233
+
234
+
235
+ # ══════════════════════════════════════════════
236
+ # LOAD RESULTS
237
+ # ══════════════════════════════════════════════
238
+ data = load_json("final_recommendations.json")
239
+ candidates = load_json("ranked_candidates.json")
240
+ retention = load_json("retention_curve.json")
241
+
242
+ if not data:
243
+ st.info("👆 Enter your YouTube URL and upload your video above to get started.")
244
+ st.stop()
245
+
246
+ recs = data.get("recommendations", [])
247
+ duration = data.get("video_duration_formatted", "N/A")
248
+ total_placed = data.get("total_placements_recommended", 0)
249
+ cands_list = candidates.get("ranked_placements", []) if candidates else []
250
+ top_ret = max((c["retention_at_t"] for c in cands_list), default=0)
251
+
252
+
253
+ # ══════════════════════════════════════════════
254
+ # METRICS
255
+ # ══════════════════════════════════════════════
256
+ m1, m2, m3, m4 = st.columns(4)
257
+ metrics = [
258
+ ("🎬 Video Duration", duration, "#ffffff"),
259
+ ("📍 Best Ad Spots Found", total_placed, "#22c55e" if total_placed > 0 else "#ef4444"),
260
+ ("🔬 Moments Analyzed", len(cands_list), "#ffffff"),
261
+ ("👀 Best Spot Retention", f"{top_ret:.0f}%", "#22c55e" if top_ret >= 50 else "#f59e0b"),
262
+ ]
263
+ for col, (label, value, color) in zip([m1, m2, m3, m4], metrics):
264
+ with col:
265
+ st.markdown(f"""
266
+ <div class="metric-box">
267
+ <div class="metric-label">{label}</div>
268
+ <div class="metric-value" style="color:{color};">{value}</div>
269
+ </div>""", unsafe_allow_html=True)
270
+
271
+ st.markdown("<br>", unsafe_allow_html=True)
272
+
273
+
274
+ # ══════════════════════════════════════════════
275
+ # RETENTION CURVE
276
+ # ══════════════════════════════════════════════
277
+ st.markdown('<div class="section-header">📈 Your Viewer Retention Curve</div>', unsafe_allow_html=True)
278
+ st.caption("This shows how many viewers are still watching at each moment. The markers show your recommended ad spots.")
279
+
280
+ if retention:
281
+ times = [p["time_seconds"] for p in retention]
282
+ values = [p["retention_percent"] for p in retention]
283
+
284
+ fig = go.Figure()
285
+ fig.add_trace(go.Scatter(
286
+ x=times, y=values, mode="lines",
287
+ name="Viewer Retention",
288
+ line=dict(color="#6366f1", width=2),
289
+ fill="tozeroy", fillcolor="rgba(99,102,241,0.15)"
290
+ ))
291
+
292
+ colors = ["#22c55e", "#f59e0b", "#ef4444"]
293
+ for i, r in enumerate(recs):
294
+ color = colors[i % len(colors)]
295
+ fig.add_vline(
296
+ x=r["timestamp_seconds"], line_dash="dash",
297
+ line_color=color, line_width=2,
298
+ annotation_text=f"📍 Ad {r['placement_number']} ({r['timestamp_formatted']})",
299
+ annotation_font_color=color
300
+ )
301
+
302
+ fig.update_layout(
303
+ plot_bgcolor="#0e1117", paper_bgcolor="#0e1117",
304
+ font_color="white", height=380,
305
+ xaxis=dict(title="Time (seconds)", gridcolor="#1f2937"),
306
+ yaxis=dict(title="Viewers Still Watching (%)", gridcolor="#1f2937", range=[0, 105]),
307
+ margin=dict(l=20, r=20, t=30, b=40)
308
+ )
309
+ st.plotly_chart(fig, use_container_width=True)
310
+
311
+ st.markdown("---")
312
+
313
+
314
+ # ══════════════════════════════════════════════
315
+ # RECOMMENDATIONS
316
+ # ══════════════════════════════════════════════
317
+ st.markdown('<div class="section-header">🎯 Where to Place Your Ad</div>', unsafe_allow_html=True)
318
+
319
+ if not recs:
320
+ st.markdown("""
321
+ <div style="background:#1c1f26;border-radius:12px;padding:1.5rem;text-align:center;">
322
+ <div style="font-size:2rem;">😕</div>
323
+ <div style="font-size:1.1rem;color:#f87171;font-weight:600;margin:0.5rem 0;">
324
+ No strong ad spots found for this video
325
+ </div>
326
+ <div style="color:#9ca3af;font-size:0.9rem;">
327
+ This usually means viewer retention dropped too quickly.<br>
328
+ Try a video where viewers watch past the halfway point.
329
+ </div>
330
+ </div>
331
+ """, unsafe_allow_html=True)
332
+ else:
333
+ for r in recs:
334
+ emoji, quality, card_class, badge_class = get_spot_quality(r["retention_at_t"])
335
+ type_label = get_type_label(r["type"])
336
+ type_tip = get_type_tip(r["type"])
337
+ ret = r["retention_at_t"]
338
+ conf_color = "#22c55e" if r["confidence"] == "HIGH" else "#f59e0b" if r["confidence"] == "MEDIUM" else "#94a3b8"
339
+
340
+ st.markdown(f"""
341
+ <div class="{card_class}">
342
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.8rem;">
343
+ <div style="font-size:1.2rem;font-weight:700;color:#ffffff;">
344
+ 📍 Ad Spot {r['placement_number']} &nbsp;—&nbsp; {r['timestamp_formatted']}
345
+ </div>
346
+ <span class="{badge_class}">{emoji} {quality}</span>
347
+ </div>
348
+ <div style="display:flex;gap:2rem;margin-bottom:0.8rem;flex-wrap:wrap;">
349
+ <div>
350
+ <div style="color:#888;font-size:0.8rem;">VIEWERS WATCHING</div>
351
+ <div style="font-size:1.5rem;font-weight:700;color:#ffffff;">{ret:.0f}%</div>
352
+ </div>
353
+ <div>
354
+ <div style="color:#888;font-size:0.8rem;">BREAK TYPE</div>
355
+ <div style="font-size:1rem;font-weight:600;color:#e2e8f0;">{type_label}</div>
356
+ </div>
357
+ <div>
358
+ <div style="color:#888;font-size:0.8rem;">CONFIDENCE</div>
359
+ <div style="font-size:1rem;font-weight:600;color:{conf_color};">{r['confidence']}</div>
360
+ </div>
361
+ </div>
362
+ <div class="tip-box">
363
+ 💡 <b>Why this spot?</b> {type_tip}<br><br>
364
+ 🎬 <b>What to do:</b> Place your sponsorship or enable mid-roll at <b>{r['timestamp_formatted']}</b>.
365
+ At this moment, <b>{ret:.0f}% of your audience</b> is still watching.
366
+ </div>
367
+ </div>
368
+ """, unsafe_allow_html=True)
369
+
370
+ st.markdown("---")
371
+
372
+
373
+ # ══════════════════════════════════════════════
374
+ # INSIGHTS FOR NEXT VIDEO
375
+ # ══════════════════════════════════════════════
376
+ st.markdown('<div class="section-header">📊 Insights for Your Next Video</div>', unsafe_allow_html=True)
377
+
378
+ if cands_list:
379
+ df = pd.DataFrame(cands_list)
380
+ best_ret = df["retention_at_t"].max()
381
+ worst_ret = df["retention_at_t"].min()
382
+ avg_ret = df["retention_at_t"].mean()
383
+ drop_time = df.loc[df["retention_at_t"].idxmin(), "timestamp_formatted"]
384
+
385
+ i1, i2, i3 = st.columns(3)
386
+ for col, label, value, color, sub in [
387
+ (i1, "🏆 Peak Retention Spot", f"{best_ret:.0f}%", "#22c55e", "viewers at best moment"),
388
+ (i2, "📉 Biggest Drop-Off At", drop_time, "#ef4444", "most viewers left here"),
389
+ (i3, "📊 Avg Retention at Breaks", f"{avg_ret:.0f}%", "#f59e0b", "across all detected moments"),
390
+ ]:
391
+ with col:
392
+ st.markdown(f"""
393
+ <div class="metric-box">
394
+ <div class="metric-label">{label}</div>
395
+ <div class="metric-value" style="color:{color};">{value}</div>
396
+ <div style="color:#888;font-size:0.8rem;">{sub}</div>
397
+ </div>""", unsafe_allow_html=True)
398
+
399
+ st.markdown("<br>", unsafe_allow_html=True)
400
+ st.markdown("#### 💡 What This Means for Your Next Video")
401
+
402
+ tips = []
403
+ if best_ret >= 60:
404
+ tips.append("✅ **Strong early retention** — your intro hooks viewers well. Keep doing what you did in the first 2 minutes.")
405
+ if worst_ret < 25:
406
+ tips.append(f"⚠️ **Viewers drop off near {drop_time}** — tighten that section or add a re-engagement hook (a question, teaser, or visual change).")
407
+ if avg_ret < 35:
408
+ tips.append("📉 **Overall retention is low** — try shorter videos or stronger pacing to keep viewers engaged longer.")
409
+ if avg_ret >= 50:
410
+ tips.append("🎉 **Great overall retention** — your audience is engaged. You can confidently place ads and expect good visibility.")
411
+ if total_placed == 0:
412
+ tips.append("🔴 **No strong ad spots this time** — aim to keep 40%+ viewers watching past the 2-minute mark.")
413
+
414
+ for tip in tips:
415
+ st.markdown(f"- {tip}")
416
+
417
+
418
+ # ══════════════════════════════════════════════
419
+ # ADVANCED TABLE
420
+ # ══════════════════════════════════════════════
421
+ st.markdown("---")
422
+ with st.expander("🔬 View All Analyzed Moments (Advanced)"):
423
+ if cands_list:
424
+ df_show = pd.DataFrame(cands_list)[["timestamp_formatted", "type", "retention_at_t", "placement_score"]]
425
+ df_show.columns = ["Timestamp", "Break Type", "Viewers Watching (%)", "ML Score"]
426
+ df_show["Break Type"] = df_show["Break Type"].map(get_type_label)
427
+ df_show["Viewers Watching (%)"] = df_show["Viewers Watching (%)"].map(lambda x: f"{x:.1f}%")
428
+ df_show["ML Score"] = df_show["ML Score"].map(lambda x: f"{x:.4f}")
429
+ st.dataframe(df_show, use_container_width=True, hide_index=True)
430
+
431
+
432
+ # ── Footer ──
433
+ st.markdown("<br>", unsafe_allow_html=True)
434
+ st.markdown(
435
+ '<div style="text-align:center;color:#4b5563;font-size:0.8rem;">Ad Placement Recommender • Built for YouTube Creators</div>',
436
+ unsafe_allow_html=True
437
+ )
pipeline.py ADDED
@@ -0,0 +1,308 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ pipeline.py — Runs full pipeline as functions (no subprocess needed on Render)
3
+ """
4
+
5
+ import json
6
+ import os
7
+ import tempfile
8
+ import numpy as np
9
+ import pandas as pd
10
+
11
+
12
+ # ── Component 1: Candidate Generator ──────────────────────────
13
+
14
+ def run_component1(video_path):
15
+ """Extract scene changes, silences, transcript boundaries from video."""
16
+ import cv2
17
+ from pydub import AudioSegment
18
+ from pydub.silence import detect_silence
19
+
20
+ candidates = []
21
+ total_duration = 0
22
+
23
+ # Get video duration
24
+ cap = cv2.VideoCapture(video_path)
25
+ fps = cap.get(cv2.CAP_PROP_FPS)
26
+ frame_count = cap.get(cv2.CAP_PROP_FRAME_COUNT)
27
+ total_duration = frame_count / fps if fps > 0 else 0
28
+
29
+ # Scene change detection
30
+ prev_frame = None
31
+ frame_idx = 0
32
+ scene_threshold = 30.0
33
+
34
+ while True:
35
+ ret, frame = cap.read()
36
+ if not ret:
37
+ break
38
+ gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
39
+ if prev_frame is not None:
40
+ diff = cv2.absdiff(gray, prev_frame)
41
+ score = diff.mean()
42
+ if score > scene_threshold:
43
+ timestamp = frame_idx / fps
44
+ if timestamp > 30: # skip first 30s
45
+ candidates.append({
46
+ "timestamp": round(timestamp, 2),
47
+ "type": "scene_change",
48
+ "score": round(float(score), 3)
49
+ })
50
+ prev_frame = gray
51
+ frame_idx += 1
52
+ cap.release()
53
+
54
+ # Silence detection
55
+ try:
56
+ audio = AudioSegment.from_file(video_path)
57
+ silences = detect_silence(audio, min_silence_len=800, silence_thresh=-40)
58
+ for start_ms, end_ms in silences:
59
+ mid = (start_ms + end_ms) / 2 / 1000
60
+ if mid > 30 and mid < total_duration - 30:
61
+ candidates.append({
62
+ "timestamp": round(mid, 2),
63
+ "type": "silence",
64
+ "score": 0.6
65
+ })
66
+ except Exception as e:
67
+ print(f"[Component 1] Audio extraction warning: {e}")
68
+
69
+ # Deduplicate — remove candidates within 5s of each other
70
+ candidates = sorted(candidates, key=lambda x: x["timestamp"])
71
+ deduped = []
72
+ for c in candidates:
73
+ if not deduped or abs(c["timestamp"] - deduped[-1]["timestamp"]) > 5:
74
+ deduped.append(c)
75
+
76
+ data = {"candidates": deduped, "total_duration": round(total_duration, 2)}
77
+ with open("candidates.json", "w") as f:
78
+ json.dump(data, f, indent=2)
79
+
80
+ print(f"[Component 1] {len(deduped)} candidates found, duration: {total_duration:.1f}s")
81
+ return data
82
+
83
+
84
+ # ── Component 2: Feature Extractor ────────────────────────────
85
+
86
+ def run_component2(video_id, creds):
87
+ """Fetch retention data and extract features."""
88
+ from youtube_analytics import get_retention_curve
89
+
90
+ with open("candidates.json") as f:
91
+ data = json.load(f)
92
+
93
+ candidates = data["candidates"]
94
+ total_duration = data["total_duration"]
95
+
96
+ curve_df = get_retention_curve(video_id, creds=creds)
97
+
98
+ rows = []
99
+ for i, c in enumerate(candidates):
100
+ t = c["timestamp"]
101
+
102
+ # Get retention at timestamp
103
+ mask = (curve_df["second"] >= t - 10) & (curve_df["second"] <= t + 10)
104
+ subset = curve_df[mask]
105
+ if subset.empty:
106
+ ret_at_t, drop_rate, recovery = 0.0, 0.0, 0.0
107
+ else:
108
+ idx = (subset["second"] - t).abs().idxmin()
109
+ ret_at_t = float(curve_df.loc[idx, "retention_pct"])
110
+ before = curve_df[curve_df["second"] < t].tail(30)
111
+ after = curve_df[curve_df["second"] > t].head(30)
112
+ further = curve_df[curve_df["second"] > t + 30].head(30)
113
+ drop_rate = float(before["retention_pct"].mean() - after["retention_pct"].mean()) if len(before) and len(after) else 0
114
+ recovery = float(after["retention_pct"].mean() - further["retention_pct"].mean()) if len(after) and len(further) else 0
115
+
116
+ time_since_last = t - candidates[i-1]["timestamp"] if i > 0 else t
117
+ rows.append({
118
+ "timestamp": round(t, 2),
119
+ "type": c["type"],
120
+ "content_score": c["score"],
121
+ "retention_at_t": round(ret_at_t, 3),
122
+ "retention_drop_rate": round(drop_rate, 3),
123
+ "retention_recovery": round(recovery, 3),
124
+ "relative_position": round(t / total_duration, 4),
125
+ "time_since_last_candidate": round(time_since_last, 2),
126
+ "label": 0
127
+ })
128
+
129
+ df = pd.DataFrame(rows)
130
+ if len(df) > 1:
131
+ df["label"] = (
132
+ (df["retention_at_t"] > df["retention_at_t"].median()) &
133
+ (df["retention_drop_rate"] < df["retention_drop_rate"].median())
134
+ ).astype(int)
135
+
136
+ df.to_csv("features.csv", index=False)
137
+ print(f"[Component 2] features.csv saved — {len(df)} candidates")
138
+ return df
139
+
140
+
141
+ # ── Component 3: ML Ranker ─────────────────────────────────────
142
+
143
+ def run_component3():
144
+ """Train LightGBM ranker and score all candidates."""
145
+ import lightgbm as lgb
146
+ from sklearn.preprocessing import LabelEncoder
147
+
148
+ df = pd.read_csv("features.csv")
149
+
150
+ feature_cols = [
151
+ "retention_at_t", "retention_drop_rate", "retention_recovery",
152
+ "relative_position", "time_since_last_candidate", "content_score"
153
+ ]
154
+
155
+ le = LabelEncoder()
156
+ df["type_enc"] = le.fit_transform(df["type"])
157
+ feature_cols.append("type_enc")
158
+
159
+ X = df[feature_cols].fillna(0)
160
+ y = df["label"].fillna(0).astype(int)
161
+
162
+ n_pos = y.sum()
163
+ print(f"[Component 3] Training LightGBM — {len(df)} samples, {n_pos} positive")
164
+
165
+ model = lgb.LGBMClassifier(
166
+ n_estimators=100,
167
+ learning_rate=0.05,
168
+ num_leaves=15,
169
+ random_state=42,
170
+ verbose=-1
171
+ )
172
+ model.fit(X, y)
173
+
174
+ scores = model.predict_proba(X)[:, 1]
175
+ df["placement_score"] = scores
176
+
177
+ def fmt(t):
178
+ return f"{int(t//60)}m {int(t%60):02d}s"
179
+
180
+ placements = []
181
+ for _, row in df.iterrows():
182
+ placements.append({
183
+ "timestamp": row["timestamp"],
184
+ "timestamp_formatted": fmt(row["timestamp"]),
185
+ "type": row["type"],
186
+ "placement_score": float(row["placement_score"]),
187
+ "retention_at_t": float(row["retention_at_t"]),
188
+ "label": int(row["label"])
189
+ })
190
+
191
+ placements = sorted(placements, key=lambda x: x["placement_score"], reverse=True)
192
+ for i, p in enumerate(placements):
193
+ p["rank"] = i + 1
194
+
195
+ result = {"ranked_placements": placements}
196
+ with open("ranked_candidates.json", "w") as f:
197
+ json.dump(result, f, indent=2)
198
+
199
+ print(f"[Component 3] Ranked {len(placements)} candidates")
200
+ return placements
201
+
202
+
203
+ # ── Component 4: Recommender ───────────────────────────────────
204
+
205
+ def run_component4():
206
+ """Apply business rules and generate final recommendations."""
207
+
208
+ CONFIG = {
209
+ "min_placement_score": 0.0,
210
+ "min_gap_seconds": 120,
211
+ "skip_intro_pct": 0.20,
212
+ "skip_outro_pct": 0.10,
213
+ "max_placements": 3,
214
+ "short_video_threshold": 480,
215
+ }
216
+
217
+ with open("ranked_candidates.json") as f:
218
+ data = json.load(f)
219
+
220
+ placements = data["ranked_placements"]
221
+ total_duration = max(p["timestamp"] for p in placements) / 0.80
222
+
223
+ # Rule 1: Score threshold
224
+ filtered = [p for p in placements if p["placement_score"] >= CONFIG["min_placement_score"]]
225
+
226
+ # Rule 2: Skip intro
227
+ intro_cut = total_duration * CONFIG["skip_intro_pct"]
228
+ filtered = [p for p in filtered if p["timestamp"] >= intro_cut]
229
+
230
+ # Rule 3: Skip outro
231
+ outro_cut = total_duration * (1 - CONFIG["skip_outro_pct"])
232
+ filtered = [p for p in filtered if p["timestamp"] <= outro_cut]
233
+
234
+ # Rule 4: Max placements
235
+ max_allow = 1 if total_duration <= CONFIG["short_video_threshold"] else CONFIG["max_placements"]
236
+
237
+ # Rule 5: Min gap (greedy)
238
+ selected = []
239
+ for p in sorted(filtered, key=lambda x: x["placement_score"], reverse=True):
240
+ if len(selected) >= max_allow:
241
+ break
242
+ too_close = any(abs(p["timestamp"] - s["timestamp"]) < CONFIG["min_gap_seconds"] for s in selected)
243
+ if not too_close:
244
+ selected.append(p)
245
+
246
+ def fmt(t):
247
+ return f"{int(t//60)}m {int(t%60):02d}s"
248
+
249
+ output = {
250
+ "video_duration_seconds": round(total_duration, 1),
251
+ "video_duration_formatted": fmt(total_duration),
252
+ "total_placements_recommended": len(selected),
253
+ "config_used": CONFIG,
254
+ "recommendations": []
255
+ }
256
+
257
+ for i, p in enumerate(sorted(selected, key=lambda x: x["timestamp"])):
258
+ ret = p["retention_at_t"]
259
+ output["recommendations"].append({
260
+ "placement_number": i + 1,
261
+ "timestamp_seconds": p["timestamp"],
262
+ "timestamp_formatted": p["timestamp_formatted"],
263
+ "type": p["type"],
264
+ "placement_score": p["placement_score"],
265
+ "retention_at_t": ret,
266
+ "confidence": (
267
+ "HIGH" if p["placement_score"] >= 0.75 else
268
+ "MEDIUM" if p["placement_score"] >= 0.50 else
269
+ "LOW"
270
+ ),
271
+ "creator_note": (
272
+ f"Place sponsored segment at {p['timestamp_formatted']} — "
273
+ f"natural {p['type'].replace('_', ' ')} detected, "
274
+ f"{ret:.1f}% viewers still watching."
275
+ )
276
+ })
277
+
278
+ with open("final_recommendations.json", "w") as f:
279
+ json.dump(output, f, indent=2)
280
+
281
+ print(f"[Component 4] {len(selected)} final placements recommended")
282
+ return output
283
+
284
+
285
+ # ── Full Pipeline ──────────────────────────────────────────────
286
+
287
+ def run_full_pipeline(video_path, video_id, creds, progress_callback=None):
288
+ """Run all 4 components in sequence. Returns final recommendations."""
289
+
290
+ def update(msg):
291
+ print(msg)
292
+ if progress_callback:
293
+ progress_callback(msg)
294
+
295
+ update("🔍 Step 1/4 — Detecting natural break points in your video...")
296
+ run_component1(video_path)
297
+
298
+ update("📊 Step 2/4 — Fetching viewer retention data from YouTube...")
299
+ run_component2(video_id, creds)
300
+
301
+ update("🤖 Step 3/4 — Running ML ranking engine...")
302
+ run_component3()
303
+
304
+ update("🎯 Step 4/4 — Generating final recommendations...")
305
+ result = run_component4()
306
+
307
+ update("✅ Analysis complete!")
308
+ return result
render.yaml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ - type: web
3
+ name: ad-placement-recommender
4
+ runtime: python
5
+ buildCommand: pip install -r requirements.txt
6
+ startCommand: streamlit run dashboard.py --server.port $PORT --server.address 0.0.0.0
7
+ envVars:
8
+ - key: PYTHON_VERSION
9
+ value: 3.11.0
requirements.txt ADDED
Binary file (4.18 kB). View file
 
simulate_and_test.py ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Quick test: simulates candidates.json then runs Component 2
3
+ No real video or API needed — run this first to verify the pipeline.
4
+ """
5
+ import json, numpy as np
6
+
7
+ total_duration = 600 # 10-minute video
8
+
9
+ # Simulate Component 1 output
10
+ np.random.seed(0)
11
+ candidates = []
12
+ for t in range(120, 540, 70):
13
+ t += np.random.randint(-10, 10)
14
+ candidates.append({
15
+ "timestamp": float(t),
16
+ "type": np.random.choice(["silence", "scene_change", "transcript_boundary"]),
17
+ "score": round(np.random.uniform(0.5, 5.0), 3)
18
+ })
19
+
20
+ with open("candidates.json", "w") as f:
21
+ json.dump({"total_duration": total_duration, "candidates": candidates}, f, indent=2)
22
+
23
+ print("candidates.json created with", len(candidates), "candidates")
24
+ print("Now run: python component2_feature_extractor.py")
test_retention.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from youtube_analytics import get_retention_curve
2
+
3
+ video_ids = [
4
+ "USVNGTw2pKM", # Dijkstra is Dead
5
+ "okPBQ0Ekafw",
6
+ "FU78qBHeQf0",
7
+ "4Rq-LY16WxM", # FlashGuard
8
+ "LpihkA9KWqo",
9
+ ]
10
+
11
+ for vid in video_ids:
12
+ print(f"\nTrying: {vid}")
13
+ df = get_retention_curve(vid)
14
+ if df is not None:
15
+ print(f" ✅ Has retention data! ({len(df)} points)")
16
+ print(df.head(5).to_string(index=False))
17
+ else:
18
+ print(f" ⚠️ No data yet")
youtube_analytics.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ youtube_analytics.py — Fetches real retention curve from YouTube Analytics API
3
+ """
4
+
5
+ import pandas as pd
6
+ import numpy as np
7
+ from googleapiclient.discovery import build
8
+
9
+
10
+ def get_video_duration(video_id, creds):
11
+ youtube = build("youtube", "v3", credentials=creds)
12
+ response = youtube.videos().list(
13
+ part="contentDetails,snippet",
14
+ id=video_id
15
+ ).execute()
16
+
17
+ if not response["items"]:
18
+ raise ValueError(f"Video {video_id} not found or is private.")
19
+
20
+ item = response["items"][0]
21
+ duration_iso = item["contentDetails"]["duration"]
22
+ title = item["snippet"]["title"]
23
+
24
+ # Parse ISO 8601 duration
25
+ import re
26
+ match = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', duration_iso)
27
+ hours = int(match.group(1) or 0)
28
+ minutes = int(match.group(2) or 0)
29
+ seconds = int(match.group(3) or 0)
30
+ total_seconds = hours * 3600 + minutes * 60 + seconds
31
+
32
+ print(f"[Analytics] Video: {title}")
33
+ print(f"[Analytics] Duration: {hours}h {minutes}m {seconds}s ({total_seconds}s)")
34
+ return total_seconds, title
35
+
36
+
37
+ def get_retention_curve(video_id, creds=None):
38
+ if creds is None:
39
+ from youtube_auth import get_credentials
40
+ creds = get_credentials()
41
+
42
+ try:
43
+ total_duration, title = get_video_duration(video_id, creds)
44
+ except Exception as e:
45
+ print(f"[Analytics] Could not get video duration: {e}")
46
+ total_duration = 600
47
+
48
+ try:
49
+ analytics = build("youtubeAnalytics", "v2", credentials=creds)
50
+ response = analytics.reports().query(
51
+ ids="channel==MINE",
52
+ startDate="2020-01-01",
53
+ endDate="2030-01-01",
54
+ metrics="audienceWatchRatio",
55
+ dimensions="elapsedVideoTimeRatio",
56
+ filters=f"video=={video_id}",
57
+ maxResults=100
58
+ ).execute()
59
+
60
+ rows = response.get("rows", [])
61
+ if not rows:
62
+ raise ValueError("No retention data returned from YouTube Analytics.")
63
+
64
+ print(f"[Analytics] Fetched {len(rows)} retention data points for {video_id}")
65
+
66
+ ratios = [r[0] for r in rows]
67
+ watch_vals = [r[1] for r in rows]
68
+ max_watch = max(watch_vals) if watch_vals else 1
69
+
70
+ df = pd.DataFrame({
71
+ "second": [r * total_duration for r in ratios],
72
+ "retention_pct": [min((v / max_watch) * 100, 100) for v in watch_vals]
73
+ })
74
+
75
+ # Save for dashboard chart
76
+ curve_json = [
77
+ {"time_seconds": round(row["second"], 2), "retention_percent": round(row["retention_pct"], 2)}
78
+ for _, row in df.iterrows()
79
+ ]
80
+ import json
81
+ with open("retention_curve.json", "w") as f:
82
+ json.dump(curve_json, f)
83
+
84
+ return df
85
+
86
+ except Exception as e:
87
+ print(f"[Analytics] Error fetching retention: {e}")
88
+ print("[Analytics] Falling back to simulated curve")
89
+ return simulate_retention_curve(total_duration)
90
+
91
+
92
+ def simulate_retention_curve(total_duration, seed=42):
93
+ np.random.seed(seed)
94
+ t = np.linspace(0, total_duration, int(total_duration))
95
+ base = 100 * np.exp(-0.003 * t)
96
+ noise = np.random.normal(0, 2, len(t))
97
+ spikes = np.zeros(len(t))
98
+ for _ in range(5):
99
+ spike_t = np.random.randint(0, len(t))
100
+ spikes[max(0, spike_t-10):spike_t+10] += np.random.uniform(3, 8)
101
+ df = pd.DataFrame({
102
+ "second": t,
103
+ "retention_pct": np.clip(base + noise + spikes, 0, 100)
104
+ })
105
+ # Save for dashboard
106
+ curve_json = [
107
+ {"time_seconds": round(row["second"], 2), "retention_percent": round(row["retention_pct"], 2)}
108
+ for _, row in df.iterrows()
109
+ ]
110
+ import json
111
+ with open("retention_curve.json", "w") as f:
112
+ json.dump(curve_json, f)
113
+ return df
youtube_auth.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ youtube_auth.py — Web-based OAuth2 for multi-user Streamlit on Render
3
+ """
4
+
5
+ import os
6
+ import json
7
+ import streamlit as st
8
+ from google.oauth2.credentials import Credentials
9
+ from google_auth_oauthlib.flow import Flow
10
+ from google.auth.transport.requests import Request
11
+
12
+ SCOPES = [
13
+ "https://www.googleapis.com/auth/youtube.readonly",
14
+ "https://www.googleapis.com/auth/yt-analytics.readonly",
15
+ ]
16
+
17
+ def get_client_config():
18
+ env_secret = os.getenv("GOOGLE_CLIENT_SECRETS")
19
+ if env_secret:
20
+ return json.loads(env_secret)
21
+ if os.path.exists("client_secret.json"):
22
+ with open("client_secret.json") as f:
23
+ return json.load(f)
24
+ raise FileNotFoundError("No Google credentials found. Set GOOGLE_CLIENT_SECRETS env variable.")
25
+
26
+ def get_redirect_uri():
27
+ render_url = os.getenv("RENDER_EXTERNAL_URL")
28
+ if render_url:
29
+ return f"{render_url}/oauth2callback"
30
+ return "http://localhost:8501/oauth2callback"
31
+
32
+ def build_flow():
33
+ config = get_client_config()
34
+ flow = Flow.from_client_config(
35
+ config,
36
+ scopes=SCOPES,
37
+ redirect_uri=get_redirect_uri()
38
+ )
39
+ return flow
40
+
41
+ def get_credentials():
42
+ if "google_creds" in st.session_state:
43
+ creds = Credentials.from_authorized_user_info(
44
+ st.session_state["google_creds"], SCOPES
45
+ )
46
+ if creds.valid:
47
+ return creds
48
+ if creds.expired and creds.refresh_token:
49
+ creds.refresh(Request())
50
+ st.session_state["google_creds"] = json.loads(creds.to_json())
51
+ return creds
52
+
53
+ params = st.query_params
54
+ if "code" in params:
55
+ try:
56
+ flow = build_flow()
57
+ flow.fetch_token(code=params["code"])
58
+ creds = flow.credentials
59
+ st.session_state["google_creds"] = json.loads(creds.to_json())
60
+ st.query_params.clear()
61
+ return creds
62
+ except Exception as e:
63
+ st.error(f"Login failed: {e}")
64
+ return None
65
+
66
+ return None
67
+
68
+ def show_login_button():
69
+ flow = build_flow()
70
+ auth_url, _ = flow.authorization_url(
71
+ prompt="consent",
72
+ access_type="offline"
73
+ )
74
+ return auth_url
75
+
76
+ def logout():
77
+ if "google_creds" in st.session_state:
78
+ del st.session_state["google_creds"]
79
+ if "user_info" in st.session_state:
80
+ del st.session_state["user_info"]