mirror of
https://github.com/yusufipk/YouTubeOutlierBadge.git
synced 2026-09-12 11:16:14 +00:00
Compare commits
8
Commits
522998a73e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6a23f8fdc9 | ||
|
|
0ed9dbf41a | ||
|
|
5e35cf1074 | ||
|
|
aee1748aba | ||
|
|
079513d5bf | ||
|
|
1e1c092055 | ||
|
|
51a83a7e96 | ||
|
|
3e70662ef8 |
@@ -1,4 +1,5 @@
|
|||||||
web-ext-artifacts/
|
web-ext-artifacts/
|
||||||
|
.claude/
|
||||||
node_modules/
|
node_modules/
|
||||||
*.xpi
|
*.xpi
|
||||||
*.zip
|
*.zip
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ would drag a mean upward and make everything else on that channel look like it
|
|||||||
never outperformed. Shorts are scored against the channel's Shorts, regular
|
never outperformed. Shorts are scored against the channel's Shorts, regular
|
||||||
videos against regular videos, and live streams enter no pool at all. The watch
|
videos against regular videos, and live streams enter no pool at all. The watch
|
||||||
page also gets a panel with the score's details and the channel's recent view
|
page also gets a panel with the score's details and the channel's recent view
|
||||||
distribution.
|
distribution. Clicking a badge opens a small menu to copy the thumbnail and
|
||||||
|
title, download the thumbnail, or save the video into the extension's own
|
||||||
|
library, where entries live in projects, can be exported and imported as JSON,
|
||||||
|
and get their scores refreshed when the page opens.
|
||||||
|
|
||||||
No server, no API key, no account: the extension calls YouTube's own internal
|
No server, no API key, no account: the extension calls YouTube's own internal
|
||||||
(InnerTube) endpoint from inside youtube.com, without session credentials. One
|
(InnerTube) endpoint from inside youtube.com, without session credentials. One
|
||||||
@@ -20,6 +23,10 @@ are only processed once they scroll into view.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Installing
|
## Installing
|
||||||
|
|
||||||
[Get it from addons.mozilla.org](https://addons.mozilla.org/en-US/firefox/addon/youtube-outlier-badge/).
|
[Get it from addons.mozilla.org](https://addons.mozilla.org/en-US/firefox/addon/youtube-outlier-badge/).
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ bilerek seçildi: tek bir viral video ortalamayı yukarı çekip aynı kanalın
|
|||||||
videolarını "outlier değil" gösterirdi. Shorts kendi havuzunda, normal videolar
|
videolarını "outlier değil" gösterirdi. Shorts kendi havuzunda, normal videolar
|
||||||
kendi havuzunda puanlanır, canlı yayınlar hiçbir havuza girmez. Video sayfasında
|
kendi havuzunda puanlanır, canlı yayınlar hiçbir havuza girmez. Video sayfasında
|
||||||
ayrıca skorun ayrıntısı ve kanalın son videolarının izlenme dağılımı görünür.
|
ayrıca skorun ayrıntısı ve kanalın son videolarının izlenme dağılımı görünür.
|
||||||
|
Rozete tıklamak küçük bir menü açar: kapak ve başlığı panoya kopyala, kapağı
|
||||||
|
indir ya da videoyu eklentinin kendi kütüphanesine kaydet; kütüphanede kayıtlar
|
||||||
|
projelere ayrılır, JSON olarak dışa ve içe aktarılır, skorlar sayfa açılınca
|
||||||
|
tazelenir.
|
||||||
|
|
||||||
Sunucu, API anahtarı, hesap yok: eklenti youtube.com içinden YouTube'un kendi
|
Sunucu, API anahtarı, hesap yok: eklenti youtube.com içinden YouTube'un kendi
|
||||||
dahili (InnerTube) ucuna oturumsuz istek atar. Kanal başına bir istek, sonucu
|
dahili (InnerTube) ucuna oturumsuz istek atar. Kanal başına bir istek, sonucu
|
||||||
@@ -18,6 +22,10 @@ işlenir.
|
|||||||
|
|
||||||

|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Kurulum
|
## Kurulum
|
||||||
|
|
||||||
[addons.mozilla.org'dan yükleyin](https://addons.mozilla.org/tr/firefox/addon/youtube-outlier-badge/).
|
[addons.mozilla.org'dan yükleyin](https://addons.mozilla.org/tr/firefox/addon/youtube-outlier-badge/).
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
/* Kütüphane sayfası: ayar penceresiyle aynı koyu palet. */
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: flex;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
font: 13px/1.45 "Segoe UI", Roboto, sans-serif;
|
||||||
|
color: #eee;
|
||||||
|
background: #202124;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Kenar çubuğu --- */
|
||||||
|
|
||||||
|
#sidebar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
width: 230px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 16px 12px;
|
||||||
|
border-right: 1px solid #3c4043;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
#projects {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row:hover { background: #2b2c2f; }
|
||||||
|
.nav-row.active { background: #3c4043; }
|
||||||
|
|
||||||
|
.nav-name {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-count { color: #9aa0a6; font-size: 12px; }
|
||||||
|
|
||||||
|
.nav-act {
|
||||||
|
display: none;
|
||||||
|
padding: 0 4px;
|
||||||
|
border: 0;
|
||||||
|
background: none;
|
||||||
|
color: #9aa0a6;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-row:hover .nav-act { display: inline; }
|
||||||
|
.nav-act:hover { color: #eee; }
|
||||||
|
|
||||||
|
.nav-edit {
|
||||||
|
width: 100%;
|
||||||
|
padding: 3px 6px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #303134;
|
||||||
|
color: #eee;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #303134;
|
||||||
|
color: #eee;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 5px 10px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover:not(:disabled) { background: #3c4043; }
|
||||||
|
button:disabled { color: #9aa0a6; cursor: default; }
|
||||||
|
|
||||||
|
#newProject, #refresh { text-align: left; }
|
||||||
|
|
||||||
|
.io { display: flex; gap: 6px; margin-top: auto; }
|
||||||
|
.io button { flex: 1; }
|
||||||
|
|
||||||
|
#notice { color: #9aa0a6; font-size: 12px; min-height: 1em; }
|
||||||
|
|
||||||
|
/* --- Ana alan --- */
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16px 18px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#search {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 340px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #303134;
|
||||||
|
color: #eee;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
#count { color: #9aa0a6; }
|
||||||
|
|
||||||
|
#grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#empty { color: #9aa0a6; }
|
||||||
|
|
||||||
|
/* --- Video kartı --- */
|
||||||
|
|
||||||
|
.card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid #3c4043;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #26272b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
padding: 2px 7px;
|
||||||
|
border-radius: 5px;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
background: rgba(0, 0, 0, 0.78);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rozetle aynı ton skalası. */
|
||||||
|
.ob-t0 { background: rgba(60, 60, 60, 0.82); color: #d0d0d0; }
|
||||||
|
.ob-t1 { background: rgba(29, 92, 168, 0.9); }
|
||||||
|
.ob-t2 { background: rgba(21, 128, 61, 0.92); }
|
||||||
|
.ob-t3 { background: rgba(180, 83, 9, 0.94); }
|
||||||
|
.ob-t4 { background: rgba(159, 18, 57, 0.95); }
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 9px 11px 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
color: #eee;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.card-meta { color: #9aa0a6; font-size: 12px; }
|
||||||
|
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-move {
|
||||||
|
width: 100%;
|
||||||
|
padding: 4px 6px;
|
||||||
|
border: 1px solid #4a4a4a;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #303134;
|
||||||
|
color: #eee;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-act {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-del:hover { background: #5c2b29; border-color: #7a3a37; }
|
||||||
|
|
||||||
|
/* İki adımlı silmenin ikinci adımı: düğme kızarır, tık onaydır. */
|
||||||
|
.card-act.armed,
|
||||||
|
.card-act.armed:hover { background: #5c2b29; border-color: #a15551; color: #ffd8d5; }
|
||||||
|
|
||||||
|
.nav-act.armed { display: inline; color: #f28b82; font-weight: 600; }
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Outlier Badge</title>
|
||||||
|
<link rel="icon" href="../icons/icon.svg">
|
||||||
|
<link rel="stylesheet" href="library.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<aside id="sidebar">
|
||||||
|
<h1 data-i18n="libTitle"></h1>
|
||||||
|
<nav id="projects"></nav>
|
||||||
|
<button id="newProject" type="button" data-i18n="libNewProject"></button>
|
||||||
|
<button id="refresh" type="button" data-i18n="libRefresh"></button>
|
||||||
|
<div class="io">
|
||||||
|
<button id="export" type="button" data-i18n="libExport"></button>
|
||||||
|
<button id="import" type="button" data-i18n="libImport"></button>
|
||||||
|
<input id="importFile" type="file" accept=".json,application/json" hidden>
|
||||||
|
</div>
|
||||||
|
<div id="notice"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<input id="search" type="search">
|
||||||
|
<span id="count"></span>
|
||||||
|
</header>
|
||||||
|
<div id="grid"></div>
|
||||||
|
<p id="empty" hidden></p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="../src/i18n.js"></script>
|
||||||
|
<script src="../src/parse.js"></script>
|
||||||
|
<script src="../src/innertube.js"></script>
|
||||||
|
<script src="../src/store.js"></script>
|
||||||
|
<script src="../src/score.js"></script>
|
||||||
|
<script src="../src/actions.js"></script>
|
||||||
|
<script src="library.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
/* Kütüphane sayfası: kaydedilen videolar, projeler, içe/dışa aktarma.
|
||||||
|
*
|
||||||
|
* Veri storage.local'daki tek "library" anahtarında durur ve sayfa
|
||||||
|
* storage.onChanged'i dinler: açık bir YouTube sekmesi menüden video
|
||||||
|
* eklediğinde burası kendini yeniler, elle tazelemek gerekmez.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var api = (typeof browser !== "undefined" ? browser : chrome);
|
||||||
|
var T = OBI18n;
|
||||||
|
var P = OBParse;
|
||||||
|
|
||||||
|
var lib = { projects: [], items: [] };
|
||||||
|
var current = "all"; /* "all" | "inbox" | proje kimliği */
|
||||||
|
var query = "";
|
||||||
|
|
||||||
|
function $(id) { return document.getElementById(id); }
|
||||||
|
|
||||||
|
function el(tag, cls, text) {
|
||||||
|
var node = document.createElement(tag);
|
||||||
|
if (cls) node.className = cls;
|
||||||
|
if (text != null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectId() {
|
||||||
|
return "p" + Date.now().toString(36) + Math.random().toString(36).slice(2, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
function projectOf(id) {
|
||||||
|
for (var i = 0; i < lib.projects.length; i++) {
|
||||||
|
if (lib.projects[i].id === id) return lib.projects[i];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Projesi silinmiş ya da hiç atanmamış her video gelen kutusundadır. */
|
||||||
|
function bucketOf(item) {
|
||||||
|
return item.projectId && projectOf(item.projectId) ? item.projectId : "inbox";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Her değişiklik depodaki güncel kopyaya uygulanır (oku-değiştir-yaz):
|
||||||
|
* sayfanın bellekteki kopyasını toptan yazmak, yazma ile onChanged yankısı
|
||||||
|
* arasındaki milisaniyelik pencerede bir YouTube sekmesinin az önce
|
||||||
|
* eklediği videoyu ezebiliyordu. Kendi yankımız yine yutulmuyor: yankı
|
||||||
|
* yazılanla aynı veridir, depo tek gerçek kaynak kalır. */
|
||||||
|
function persist(mutate) {
|
||||||
|
return OBStore.readLibrary().then(function (fresh) {
|
||||||
|
mutate(fresh);
|
||||||
|
return OBStore.writeLibrary(fresh).then(function () {
|
||||||
|
lib = fresh;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* confirm() penceresi yok: Firefox'un eklenti sekmelerinde dialoglar
|
||||||
|
* güvenilmez davranabiliyor ve olay döngüsünü bloklamaları başka hatalar
|
||||||
|
* doğuruyor. Silme iki adım: ilk tık düğmeyi "Silinsin?" yapar, 3 saniye
|
||||||
|
* içinde ikinci tık siler. */
|
||||||
|
function armedDelete(btn, onDelete) {
|
||||||
|
btn.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (btn.dataset.armed) { onDelete(); return; }
|
||||||
|
btn.dataset.armed = "1";
|
||||||
|
var old = btn.textContent;
|
||||||
|
btn.textContent = T.t("libConfirmDelete");
|
||||||
|
btn.classList.add("armed");
|
||||||
|
setTimeout(function () {
|
||||||
|
delete btn.dataset.armed;
|
||||||
|
btn.textContent = old;
|
||||||
|
btn.classList.remove("armed");
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Kenar çubuğu ---------------------------------------------------- */
|
||||||
|
|
||||||
|
function counts() {
|
||||||
|
var map = { all: lib.items.length, inbox: 0 };
|
||||||
|
lib.items.forEach(function (item) {
|
||||||
|
var b = bucketOf(item);
|
||||||
|
map[b] = (map[b] || 0) + 1;
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function navRow(id, name, count, fixed) {
|
||||||
|
var row = el("div", "nav-row" + (current === id ? " active" : ""));
|
||||||
|
row.appendChild(el("span", "nav-name", name));
|
||||||
|
row.appendChild(el("span", "nav-count", String(count || 0)));
|
||||||
|
row.addEventListener("click", function () {
|
||||||
|
current = id;
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
if (!fixed) {
|
||||||
|
var rename = el("button", "nav-act", "✎");
|
||||||
|
rename.type = "button";
|
||||||
|
rename.title = T.t("libRenameProject");
|
||||||
|
rename.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
editName(row, id);
|
||||||
|
});
|
||||||
|
var del = el("button", "nav-act", "×");
|
||||||
|
del.type = "button";
|
||||||
|
del.title = T.t("libDeleteProject") + ". " + T.t("libDeleteProjectHint") + ".";
|
||||||
|
armedDelete(del, function () {
|
||||||
|
if (current === id) current = "inbox";
|
||||||
|
persist(function (l) {
|
||||||
|
l.projects = l.projects.filter(function (q) { return q.id !== id; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(rename);
|
||||||
|
row.appendChild(del);
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editName(row, id) {
|
||||||
|
var p = projectOf(id);
|
||||||
|
if (!p) return;
|
||||||
|
var input = el("input", "nav-edit");
|
||||||
|
input.value = p.name;
|
||||||
|
row.textContent = "";
|
||||||
|
row.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
/* finished bayrağı şart: render girdiyi DOM'dan sökünce Chromium blur'u
|
||||||
|
* senkron ateşler ve bayrak olmadan iptal edilen ad blur üzerinden yine
|
||||||
|
* kaydedilirdi. */
|
||||||
|
var finished = false;
|
||||||
|
var done = function (save) {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
var name = input.value.trim();
|
||||||
|
if (!save || !name) { render(); return; }
|
||||||
|
persist(function (l) {
|
||||||
|
for (var i = 0; i < l.projects.length; i++) {
|
||||||
|
if (l.projects[i].id === id) l.projects[i].name = name;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
input.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") done(true);
|
||||||
|
if (e.key === "Escape") done(false);
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", function () { done(true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSidebar() {
|
||||||
|
var nav = $("projects");
|
||||||
|
nav.textContent = "";
|
||||||
|
var c = counts();
|
||||||
|
nav.appendChild(navRow("all", T.t("libAll"), c.all, true));
|
||||||
|
nav.appendChild(navRow("inbox", T.t("libInbox"), c.inbox, true));
|
||||||
|
lib.projects.forEach(function (p) {
|
||||||
|
nav.appendChild(navRow(p.id, p.name, c[p.id], false));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addProject() {
|
||||||
|
var nav = $("projects");
|
||||||
|
var row = el("div", "nav-row");
|
||||||
|
var input = el("input", "nav-edit");
|
||||||
|
input.placeholder = T.t("libProjectName");
|
||||||
|
row.appendChild(input);
|
||||||
|
nav.appendChild(row);
|
||||||
|
input.focus();
|
||||||
|
var finished = false;
|
||||||
|
var done = function (save) {
|
||||||
|
if (finished) return;
|
||||||
|
finished = true;
|
||||||
|
var name = input.value.trim();
|
||||||
|
if (save && name) {
|
||||||
|
var p = { id: projectId(), name: name, createdAt: Date.now() };
|
||||||
|
current = p.id;
|
||||||
|
persist(function (l) { l.projects.push(p); });
|
||||||
|
} else {
|
||||||
|
row.remove();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
input.addEventListener("keydown", function (e) {
|
||||||
|
if (e.key === "Enter") done(true);
|
||||||
|
if (e.key === "Escape") done(false);
|
||||||
|
});
|
||||||
|
input.addEventListener("blur", function () { done(true); });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Video kartları -------------------------------------------------- */
|
||||||
|
|
||||||
|
function actionButton(labelKey, action) {
|
||||||
|
var btn = el("button", "card-act", T.t(labelKey));
|
||||||
|
btn.type = "button";
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
btn.disabled = true;
|
||||||
|
var old = btn.textContent;
|
||||||
|
action().then(function () {
|
||||||
|
btn.textContent = T.t("libDone");
|
||||||
|
setTimeout(function () { btn.textContent = old; btn.disabled = false; }, 1200);
|
||||||
|
}, function () {
|
||||||
|
btn.textContent = T.t("menuFailed");
|
||||||
|
setTimeout(function () { btn.textContent = old; btn.disabled = false; }, 1500);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return btn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveSelect(item) {
|
||||||
|
var select = el("select", "card-move");
|
||||||
|
var opt = function (value, text) {
|
||||||
|
var o = el("option", null, text);
|
||||||
|
o.value = value;
|
||||||
|
select.appendChild(o);
|
||||||
|
};
|
||||||
|
opt("inbox", T.t("libInbox"));
|
||||||
|
lib.projects.forEach(function (p) { opt(p.id, p.name); });
|
||||||
|
select.value = bucketOf(item);
|
||||||
|
select.addEventListener("change", function () {
|
||||||
|
var value = select.value === "inbox" ? null : select.value;
|
||||||
|
persist(function (l) {
|
||||||
|
for (var i = 0; i < l.items.length; i++) {
|
||||||
|
if (l.items[i].videoId === item.videoId) l.items[i].projectId = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return select;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(item) {
|
||||||
|
var box = el("div", "card");
|
||||||
|
|
||||||
|
var thumb = el("a", "thumb");
|
||||||
|
thumb.href = item.url || OBActions.watchUrl(item.videoId, item.isShort);
|
||||||
|
thumb.target = "_blank";
|
||||||
|
thumb.rel = "noopener";
|
||||||
|
var img = el("img");
|
||||||
|
img.src = OBActions.thumbnailUrl(item.videoId, "hqdefault");
|
||||||
|
img.loading = "lazy";
|
||||||
|
img.alt = "";
|
||||||
|
thumb.appendChild(img);
|
||||||
|
if (item.score != null) {
|
||||||
|
thumb.appendChild(el("span", "chip " + P.scoreTone(item.score), P.scoreLabel(item.score)));
|
||||||
|
}
|
||||||
|
box.appendChild(thumb);
|
||||||
|
|
||||||
|
var body = el("div", "card-body");
|
||||||
|
var title = el("a", "card-title", item.title || item.videoId);
|
||||||
|
title.href = thumb.href;
|
||||||
|
title.target = "_blank";
|
||||||
|
title.rel = "noopener";
|
||||||
|
title.title = item.title || "";
|
||||||
|
body.appendChild(title);
|
||||||
|
|
||||||
|
var meta = [];
|
||||||
|
if (item.channel) meta.push(item.channel);
|
||||||
|
if (item.savedAt) {
|
||||||
|
meta.push(new Date(item.savedAt).toLocaleDateString(T.lang() === "tr" ? "tr-TR" : "en-US"));
|
||||||
|
}
|
||||||
|
body.appendChild(el("div", "card-meta", meta.join(" · ")));
|
||||||
|
|
||||||
|
body.appendChild(moveSelect(item));
|
||||||
|
|
||||||
|
var row = el("div", "card-row");
|
||||||
|
var copyImg = actionButton("libCopyImage", function () {
|
||||||
|
return OBActions.copyImage(item.videoId, item.title, item.channel);
|
||||||
|
});
|
||||||
|
copyImg.title = T.t("menuCopyImage");
|
||||||
|
row.appendChild(copyImg);
|
||||||
|
var copyTxt = actionButton("libCopyText", function () {
|
||||||
|
return OBActions.copyText(item.videoId, item.title, item.isShort);
|
||||||
|
});
|
||||||
|
copyTxt.title = T.t("menuCopyText");
|
||||||
|
row.appendChild(copyTxt);
|
||||||
|
row.appendChild(actionButton("libDownload", function () {
|
||||||
|
return OBActions.download(item.videoId, item.title, item.channel);
|
||||||
|
}));
|
||||||
|
var del = el("button", "card-act card-del", T.t("libDelete"));
|
||||||
|
del.type = "button";
|
||||||
|
armedDelete(del, function () {
|
||||||
|
persist(function (l) {
|
||||||
|
l.items = l.items.filter(function (it) { return it.videoId !== item.videoId; });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
row.appendChild(del);
|
||||||
|
body.appendChild(row);
|
||||||
|
|
||||||
|
box.appendChild(body);
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleItems() {
|
||||||
|
var q = query.toLowerCase();
|
||||||
|
return lib.items.filter(function (item) {
|
||||||
|
if (current !== "all" && bucketOf(item) !== current) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
return (item.title || "").toLowerCase().indexOf(q) >= 0
|
||||||
|
|| (item.channel || "").toLowerCase().indexOf(q) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid() {
|
||||||
|
var grid = $("grid");
|
||||||
|
grid.textContent = "";
|
||||||
|
var items = visibleItems();
|
||||||
|
items.forEach(function (item) { grid.appendChild(card(item)); });
|
||||||
|
$("count").textContent = T.t("libCount", items.length);
|
||||||
|
$("empty").hidden = items.length > 0;
|
||||||
|
$("empty").textContent = T.t("libEmpty");
|
||||||
|
}
|
||||||
|
|
||||||
|
function render() {
|
||||||
|
renderSidebar();
|
||||||
|
renderGrid();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Skor tazeleme --------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Kaydedilen skor kaydedildiği anın görüntüsü. Sayfa açılınca önbellek
|
||||||
|
* ömründen (ttlHours ayarı) eski olanlar yeniden puanlanır; düğme hepsini
|
||||||
|
* zorla tazeler. Video başına bir videoDetails isteği gider, kanal
|
||||||
|
* baseline'ları içerik betiğiyle paylaşılan önbellekten okunur (taze
|
||||||
|
* değilse OBScore kendisi çeker, kanal istekleri 4'le sınırlı). Büyük bir
|
||||||
|
* kütüphanede bu yine de çok istek demek; aynı anda en çok 4 video işlenir
|
||||||
|
* ve ttl dolmadan otomatik tazeleme tekrarlanmaz. */
|
||||||
|
var MAX_REFRESH_PARALLEL = 4;
|
||||||
|
var refreshing = false;
|
||||||
|
|
||||||
|
function refreshItem(it) {
|
||||||
|
return OBTube.videoDetails(it.videoId).then(function (d) {
|
||||||
|
if (!d.channelId) throw new Error("kanal bulunamadı");
|
||||||
|
return OBScore.baseline(d.channelId, !!it.isShort).then(function (blob) {
|
||||||
|
var own = OBScore.lookupViews(it.videoId, blob);
|
||||||
|
var views = d.views != null ? d.views : (own ? own.views : null);
|
||||||
|
var r = OBScore.score(it.videoId, views, !!it.isShort, blob);
|
||||||
|
var patch = { scoredAt: Date.now() };
|
||||||
|
if (d.title) patch.title = d.title;
|
||||||
|
if (views != null) patch.views = views;
|
||||||
|
if (r.score != null) patch.score = r.score;
|
||||||
|
return { videoId: it.videoId, patch: patch, changed: r.score != null };
|
||||||
|
});
|
||||||
|
}, function () {
|
||||||
|
/* Video silinmiş ya da ulaşılamıyor: skor olduğu gibi kalır, zaman yine
|
||||||
|
* damgalanır ki her açılışta aynı video için istek yağmasın. */
|
||||||
|
return { videoId: it.videoId, patch: { scoredAt: Date.now() }, changed: false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshScores(force) {
|
||||||
|
if (refreshing) return;
|
||||||
|
OBStore.settings().then(function (s) {
|
||||||
|
var ttlMs = (s.ttlHours || 24) * 3600 * 1000;
|
||||||
|
var stale = lib.items.filter(function (it) {
|
||||||
|
return force || !it.scoredAt || Date.now() - it.scoredAt > ttlMs;
|
||||||
|
});
|
||||||
|
if (!stale.length) return;
|
||||||
|
refreshing = true;
|
||||||
|
$("refresh").disabled = true;
|
||||||
|
notice(T.t("libRefreshing", stale.length));
|
||||||
|
|
||||||
|
/* Güncellemeler videoId ile sonda uygulanır: tazeleme sürerken silme
|
||||||
|
* veya taşıma yapılabilir, eski nesne referansına yazmak kaybolurdu. */
|
||||||
|
var queueItems = stale.slice();
|
||||||
|
var active = 0;
|
||||||
|
var updates = [];
|
||||||
|
|
||||||
|
var finish = function () {
|
||||||
|
var changed = 0;
|
||||||
|
persist(function (l) {
|
||||||
|
updates.forEach(function (u) {
|
||||||
|
for (var i = 0; i < l.items.length; i++) {
|
||||||
|
if (l.items[i].videoId === u.videoId) {
|
||||||
|
Object.assign(l.items[i], u.patch);
|
||||||
|
if (u.changed) changed++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).then(function () {
|
||||||
|
refreshing = false;
|
||||||
|
$("refresh").disabled = false;
|
||||||
|
notice(T.t("libRefreshed", changed));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var startOne = function (it) {
|
||||||
|
active++;
|
||||||
|
refreshItem(it).then(function (u) {
|
||||||
|
updates.push(u);
|
||||||
|
}).catch(function () {}).then(function () {
|
||||||
|
active--;
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
var pump = function () {
|
||||||
|
while (active < MAX_REFRESH_PARALLEL && queueItems.length) {
|
||||||
|
startOne(queueItems.shift());
|
||||||
|
}
|
||||||
|
if (!active && !queueItems.length) finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
pump();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- İçe / dışa aktarma ---------------------------------------------- */
|
||||||
|
|
||||||
|
function notice(text) {
|
||||||
|
$("notice").textContent = text;
|
||||||
|
setTimeout(function () {
|
||||||
|
if ($("notice").textContent === text) $("notice").textContent = "";
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportLibrary() {
|
||||||
|
var payload = {
|
||||||
|
app: "outlier-badge",
|
||||||
|
v: 1,
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
projects: lib.projects,
|
||||||
|
items: lib.items
|
||||||
|
};
|
||||||
|
var blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = "outlier-badge-library.json";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Birleştirme alan bazında kayıpsız: yeni videolar eklenir, mevcut videoda
|
||||||
|
* içe aktarılan alanlar boşları doldurur ama yereldeki proje ataması,
|
||||||
|
* eski bir dışa aktarımda proje yoksa geri alınmaz; savedAt/scoredAt
|
||||||
|
* ikisinden yenisi kalır. Böylece bayat bir yedeği içe almak videoları
|
||||||
|
* gelen kutusuna geri fırlatmaz. */
|
||||||
|
function importLibrary(text) {
|
||||||
|
var data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch (err) {
|
||||||
|
notice(T.t("libImportFailed", err.message));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var projects = Array.isArray(data.projects) ? data.projects : [];
|
||||||
|
var items = Array.isArray(data.items) ? data.items : [];
|
||||||
|
var addedProjects = 0, addedItems = 0;
|
||||||
|
|
||||||
|
persist(function (l) {
|
||||||
|
projects.forEach(function (p) {
|
||||||
|
if (!p || !p.id || !p.name) return;
|
||||||
|
var mine = null;
|
||||||
|
for (var i = 0; i < l.projects.length; i++) {
|
||||||
|
if (l.projects[i].id === p.id) mine = l.projects[i];
|
||||||
|
}
|
||||||
|
if (mine) { mine.name = String(p.name); return; }
|
||||||
|
l.projects.push({ id: p.id, name: String(p.name), createdAt: p.createdAt || Date.now() });
|
||||||
|
addedProjects++;
|
||||||
|
});
|
||||||
|
|
||||||
|
items.forEach(function (it) {
|
||||||
|
if (!it || !it.videoId) return;
|
||||||
|
var local = null;
|
||||||
|
l.items = l.items.filter(function (mine) {
|
||||||
|
if (mine.videoId === it.videoId) { local = mine; return false; }
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
l.items.push({
|
||||||
|
videoId: String(it.videoId),
|
||||||
|
title: String(it.title || (local && local.title) || ""),
|
||||||
|
channel: String(it.channel || (local && local.channel) || ""),
|
||||||
|
url: String(it.url || (local && local.url) || ""),
|
||||||
|
isShort: !!(it.isShort != null ? it.isShort : local && local.isShort),
|
||||||
|
score: typeof it.score === "number" ? it.score : (local ? local.score : null),
|
||||||
|
views: typeof it.views === "number" ? it.views : (local ? local.views : null),
|
||||||
|
savedAt: Math.max(it.savedAt || 0, (local && local.savedAt) || 0) || Date.now(),
|
||||||
|
scoredAt: Math.max(it.scoredAt || 0, (local && local.scoredAt) || 0) || null,
|
||||||
|
projectId: it.projectId || (local ? local.projectId : null)
|
||||||
|
});
|
||||||
|
if (!local) addedItems++;
|
||||||
|
});
|
||||||
|
|
||||||
|
l.items.sort(function (a, b) { return (b.savedAt || 0) - (a.savedAt || 0); });
|
||||||
|
}).then(function () {
|
||||||
|
notice(T.t("libImported", addedItems, addedProjects));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Kurulum --------------------------------------------------------- */
|
||||||
|
|
||||||
|
function applyTexts() {
|
||||||
|
document.title = "Outlier Badge · " + T.t("libTitle");
|
||||||
|
var nodes = document.querySelectorAll("[data-i18n]");
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
nodes[i].textContent = T.t(nodes[i].dataset.i18n);
|
||||||
|
}
|
||||||
|
$("search").placeholder = T.t("libSearch");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bind() {
|
||||||
|
$("newProject").addEventListener("click", addProject);
|
||||||
|
$("refresh").addEventListener("click", function () { refreshScores(true); });
|
||||||
|
$("export").addEventListener("click", exportLibrary);
|
||||||
|
$("import").addEventListener("click", function () { $("importFile").click(); });
|
||||||
|
$("importFile").addEventListener("change", function () {
|
||||||
|
var file = $("importFile").files[0];
|
||||||
|
if (!file) return;
|
||||||
|
file.text().then(importLibrary, function (err) {
|
||||||
|
notice(T.t("libImportFailed", err.message));
|
||||||
|
});
|
||||||
|
$("importFile").value = "";
|
||||||
|
});
|
||||||
|
$("search").addEventListener("input", function () {
|
||||||
|
query = $("search").value.trim();
|
||||||
|
renderGrid();
|
||||||
|
});
|
||||||
|
api.storage.onChanged.addListener(function (changes, area) {
|
||||||
|
if (area !== "local" || !changes.library) return;
|
||||||
|
lib = changes.library.newValue || { projects: [], items: [] };
|
||||||
|
if (!Array.isArray(lib.projects)) lib.projects = [];
|
||||||
|
if (!Array.isArray(lib.items)) lib.items = [];
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
OBStore.settings().then(function (s) {
|
||||||
|
T.init(s.language);
|
||||||
|
applyTexts();
|
||||||
|
bind();
|
||||||
|
return OBStore.readLibrary();
|
||||||
|
}).then(function (data) {
|
||||||
|
lib = data;
|
||||||
|
render();
|
||||||
|
refreshScores(false);
|
||||||
|
});
|
||||||
|
})();
|
||||||
+4
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "__MSG_extName__",
|
"name": "__MSG_extName__",
|
||||||
"version": "0.1.3",
|
"version": "0.2.0",
|
||||||
"description": "__MSG_extDescription__",
|
"description": "__MSG_extDescription__",
|
||||||
"default_locale": "en",
|
"default_locale": "en",
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"permissions": ["storage"],
|
"permissions": ["storage", "clipboardWrite"],
|
||||||
"host_permissions": ["*://www.youtube.com/*"],
|
"host_permissions": ["*://www.youtube.com/*"],
|
||||||
"content_scripts": [
|
"content_scripts": [
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,8 @@
|
|||||||
"src/innertube.js",
|
"src/innertube.js",
|
||||||
"src/store.js",
|
"src/store.js",
|
||||||
"src/score.js",
|
"src/score.js",
|
||||||
|
"src/actions.js",
|
||||||
|
"src/menu.js",
|
||||||
"src/badge.js",
|
"src/badge.js",
|
||||||
"src/panel.js",
|
"src/panel.js",
|
||||||
"src/content.js"
|
"src/content.js"
|
||||||
|
|||||||
+2
-2
@@ -16,7 +16,7 @@ import sys
|
|||||||
import zipfile
|
import zipfile
|
||||||
|
|
||||||
ROOT = os.path.dirname(os.path.abspath(__file__))
|
ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||||
INCLUDE_DIRS = ["src", "popup", "icons", "_locales"]
|
INCLUDE_DIRS = ["src", "popup", "library", "icons", "_locales"]
|
||||||
INCLUDE_FILES = ["manifest.json", "LICENSE"] # GPL kopyası pakette de bulunmalı
|
INCLUDE_FILES = ["manifest.json", "LICENSE"] # GPL kopyası pakette de bulunmalı
|
||||||
OUTPUT = "outlier-badge.zip"
|
OUTPUT = "outlier-badge.zip"
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ def check_syntax():
|
|||||||
"""node varsa her JS dosyasını ayrıştırmayı dene: bozuk bir dosyayı
|
"""node varsa her JS dosyasını ayrıştırmayı dene: bozuk bir dosyayı
|
||||||
AMO'ya yükleyip incelemede öğrenmek yerine burada görelim."""
|
AMO'ya yükleyip incelemede öğrenmek yerine burada görelim."""
|
||||||
files = []
|
files = []
|
||||||
for d in ["src", "popup"]:
|
for d in ["src", "popup", "library"]:
|
||||||
for root, _, names in os.walk(os.path.join(ROOT, d)):
|
for root, _, names in os.walk(os.path.join(ROOT, d)):
|
||||||
files += [os.path.join(root, n) for n in names if n.endswith(".js")]
|
files += [os.path.join(root, n) for n in names if n.endswith(".js")]
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -47,6 +47,8 @@
|
|||||||
<select id="language"></select>
|
<select id="language"></select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
<button id="library" type="button" class="wide" data-i18n="popupLibrary"></button>
|
||||||
|
|
||||||
<div class="stats" id="stats"></div>
|
<div class="stats" id="stats"></div>
|
||||||
|
|
||||||
<button id="clear" type="button" class="wide" data-i18n="popupClear"></button>
|
<button id="clear" type="button" class="wide" data-i18n="popupClear"></button>
|
||||||
|
|||||||
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
var api = (typeof browser !== "undefined" ? browser : chrome);
|
var api = (typeof browser !== "undefined" ? browser : chrome);
|
||||||
var T = OBI18n;
|
var T = OBI18n;
|
||||||
|
/* Sadece youtube.com: kapaklar (i.ytimg.com) ACAO:* gönderdiği için host
|
||||||
|
* izni istemez ve buraya eklemek, sadece youtube iznini vermiş mevcut
|
||||||
|
* kullanıcılara güncellemede yersiz bir uyarı gösterirdi. */
|
||||||
var ORIGINS = { origins: ["*://www.youtube.com/*"] };
|
var ORIGINS = { origins: ["*://www.youtube.com/*"] };
|
||||||
|
|
||||||
var FIELDS = [
|
var FIELDS = [
|
||||||
@@ -108,6 +111,11 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$("library").addEventListener("click", function () {
|
||||||
|
api.tabs.create({ url: api.runtime.getURL("library/library.html") });
|
||||||
|
window.close();
|
||||||
|
});
|
||||||
|
|
||||||
$("clear").addEventListener("click", function () {
|
$("clear").addEventListener("click", function () {
|
||||||
OBStore.clearBaselines().then(function () {
|
OBStore.clearBaselines().then(function () {
|
||||||
$("stats").textContent = T.t("popupCleared");
|
$("stats").textContent = T.t("popupCleared");
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 41 KiB |
+256
@@ -0,0 +1,256 @@
|
|||||||
|
/* Rozet menüsünün ve kütüphane sayfasının ortak eylemleri: kart görselini
|
||||||
|
* panoya kopyalama, indirme, kütüphaneye kaydetme.
|
||||||
|
*
|
||||||
|
* Hem içerik betiğinde hem eklenti sayfasında çalışır; YouTube DOM'una ve
|
||||||
|
* InnerTube'a bağımlı değildir. Kapak i.ytimg.com'dan düz CORS ile çekilir:
|
||||||
|
* sunucu Access-Control-Allow-Origin: * gönderdiği için ayrı host izni
|
||||||
|
* gerekmiyor. Google bu başlığı bir gün kaldırırsa görsel kopyalama ve
|
||||||
|
* indirme "olmadı" hatasına düşer; metin kopyalama etkilenmez.
|
||||||
|
*/
|
||||||
|
var OBActions = (function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
/* maxres her videoda yok; olmayanında YouTube 404 yerine küçük gri bir yer
|
||||||
|
* tutucu da dönebiliyor. Sırayla denenir, ilk gerçek görsel kazanır.
|
||||||
|
* hqdefault her videoda vardır. */
|
||||||
|
var SIZES = ["maxresdefault", "sddefault", "hqdefault"];
|
||||||
|
var PLACEHOLDER_MAX_BYTES = 4096;
|
||||||
|
|
||||||
|
function thumbnailUrl(videoId, size) {
|
||||||
|
return "https://i.ytimg.com/vi/" + videoId + "/" + (size || "hqdefault") + ".jpg";
|
||||||
|
}
|
||||||
|
|
||||||
|
function watchUrl(videoId, isShort) {
|
||||||
|
return isShort
|
||||||
|
? "https://www.youtube.com/shorts/" + videoId
|
||||||
|
: "https://www.youtube.com/watch?v=" + videoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fetchThumbnail(videoId) {
|
||||||
|
var attempt = function (i) {
|
||||||
|
if (i >= SIZES.length) return Promise.reject(new Error("kapak alınamadı"));
|
||||||
|
var next = function () { return attempt(i + 1); };
|
||||||
|
return fetch(thumbnailUrl(videoId, SIZES[i]), { credentials: "omit" }).then(function (resp) {
|
||||||
|
if (!resp.ok) return next();
|
||||||
|
return resp.blob().then(function (blob) {
|
||||||
|
if (blob.size < PLACEHOLDER_MAX_BYTES && i + 1 < SIZES.length) return next();
|
||||||
|
return blob;
|
||||||
|
});
|
||||||
|
}, next);
|
||||||
|
};
|
||||||
|
return attempt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Kart görseli ---------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Kopyala ve indir düz kapağı değil, YouTube'un koyu arayüzündeki karta
|
||||||
|
* benzeyen tek bir görsel üretir: üstte köşeleri yuvarlak kapak, altında
|
||||||
|
* başlık ve kanal adı. hq/sd kapaklar 4:3 geldiği için üst-alt siyah
|
||||||
|
* bantlar 16:9 merkez kırpımıyla atılıyor; maxres zaten 16:9. */
|
||||||
|
var CARD = {
|
||||||
|
width: 1200,
|
||||||
|
pad: 48,
|
||||||
|
radius: 24,
|
||||||
|
gap: 36,
|
||||||
|
titleFont: "600 46px 'Roboto', 'Segoe UI', Arial, sans-serif",
|
||||||
|
titleLine: 60,
|
||||||
|
titleMaxLines: 3,
|
||||||
|
channelFont: "400 34px 'Roboto', 'Segoe UI', Arial, sans-serif",
|
||||||
|
channelHeight: 40,
|
||||||
|
channelGap: 16,
|
||||||
|
bg: "#0f0f0f",
|
||||||
|
fg: "#f1f1f1",
|
||||||
|
dim: "#aaaaaa"
|
||||||
|
};
|
||||||
|
|
||||||
|
function roundedPath(ctx, x, y, w, h, r) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + r, y);
|
||||||
|
ctx.arcTo(x + w, y, x + w, y + h, r);
|
||||||
|
ctx.arcTo(x + w, y + h, x, y + h, r);
|
||||||
|
ctx.arcTo(x, y + h, x, y, r);
|
||||||
|
ctx.arcTo(x, y, x + w, y, r);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sığmayan tek parça sözcük (URL, etiket zinciri, boşluksuz CJK başlık)
|
||||||
|
* harf harf bölünür; yoksa satır kanvasın sağ kenarından taşıp kırpılırdı. */
|
||||||
|
function breakWord(ctx, word, maxWidth) {
|
||||||
|
var parts = [];
|
||||||
|
var cur = "";
|
||||||
|
for (var i = 0; i < word.length; i++) {
|
||||||
|
var probe = cur + word.charAt(i);
|
||||||
|
if (cur && ctx.measureText(probe).width > maxWidth) {
|
||||||
|
parts.push(cur);
|
||||||
|
cur = word.charAt(i);
|
||||||
|
} else {
|
||||||
|
cur = probe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cur) parts.push(cur);
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapLines(ctx, text, maxWidth, maxLines) {
|
||||||
|
var words = [];
|
||||||
|
String(text).split(/\s+/).filter(Boolean).forEach(function (w) {
|
||||||
|
if (ctx.measureText(w).width > maxWidth) {
|
||||||
|
words.push.apply(words, breakWord(ctx, w, maxWidth));
|
||||||
|
} else {
|
||||||
|
words.push(w);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var lines = [];
|
||||||
|
var line = "";
|
||||||
|
var cut = false;
|
||||||
|
for (var i = 0; i < words.length; i++) {
|
||||||
|
var probe = line ? line + " " + words[i] : words[i];
|
||||||
|
if (line && ctx.measureText(probe).width > maxWidth) {
|
||||||
|
lines.push(line);
|
||||||
|
line = words[i];
|
||||||
|
if (lines.length === maxLines) { cut = true; line = ""; break; }
|
||||||
|
} else {
|
||||||
|
line = probe;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (line) lines.push(line);
|
||||||
|
if (cut && lines.length) {
|
||||||
|
var last = lines[lines.length - 1];
|
||||||
|
while (last && ctx.measureText(last + "…").width > maxWidth) {
|
||||||
|
last = last.slice(0, -1);
|
||||||
|
}
|
||||||
|
lines[lines.length - 1] = last + "…";
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compose(videoId, title, channel) {
|
||||||
|
return fetchThumbnail(videoId).then(function (blob) {
|
||||||
|
return createImageBitmap(blob);
|
||||||
|
}).then(function (bmp) {
|
||||||
|
var sw = bmp.width;
|
||||||
|
var sh = Math.round(sw * 9 / 16);
|
||||||
|
if (sh > bmp.height) { sh = bmp.height; sw = Math.round(sh * 16 / 9); }
|
||||||
|
var sx = Math.round((bmp.width - sw) / 2);
|
||||||
|
var sy = Math.round((bmp.height - sh) / 2);
|
||||||
|
|
||||||
|
var W = CARD.width;
|
||||||
|
var pad = CARD.pad;
|
||||||
|
var tw = W - pad * 2;
|
||||||
|
var th = Math.round(tw * 9 / 16);
|
||||||
|
|
||||||
|
var measure = document.createElement("canvas").getContext("2d");
|
||||||
|
measure.font = CARD.titleFont;
|
||||||
|
var lines = wrapLines(measure, title || "", tw, CARD.titleMaxLines);
|
||||||
|
|
||||||
|
var textTop = pad + th + CARD.gap;
|
||||||
|
var H = lines.length ? textTop + lines.length * CARD.titleLine : pad + th;
|
||||||
|
if (channel) H += CARD.channelGap + CARD.channelHeight;
|
||||||
|
H += pad;
|
||||||
|
|
||||||
|
var canvas = document.createElement("canvas");
|
||||||
|
canvas.width = W;
|
||||||
|
canvas.height = H;
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
ctx.fillStyle = CARD.bg;
|
||||||
|
ctx.fillRect(0, 0, W, H);
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
roundedPath(ctx, pad, pad, tw, th, CARD.radius);
|
||||||
|
ctx.clip();
|
||||||
|
ctx.drawImage(bmp, sx, sy, sw, sh, pad, pad, tw, th);
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
ctx.fillStyle = CARD.fg;
|
||||||
|
ctx.font = CARD.titleFont;
|
||||||
|
for (var i = 0; i < lines.length; i++) {
|
||||||
|
ctx.fillText(lines[i], pad, textTop + i * CARD.titleLine);
|
||||||
|
}
|
||||||
|
if (channel) {
|
||||||
|
ctx.fillStyle = CARD.dim;
|
||||||
|
ctx.font = CARD.channelFont;
|
||||||
|
ctx.fillText(channel, pad, textTop + lines.length * CARD.titleLine + CARD.channelGap);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
canvas.toBlob(function (png) {
|
||||||
|
if (png) resolve(png);
|
||||||
|
else reject(new Error("png dönüşümü başarısız"));
|
||||||
|
}, "image/png");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Görsel ve metin ayrı kopyalanır. İkisini tek pano yazımında vermek
|
||||||
|
* denendi; yapıştırılan alan biçimi kendisi seçiyor ve metin kutuları hep
|
||||||
|
* düz metni aldığı için görselin gittiği yeri kullanıcı kestiremiyordu.
|
||||||
|
* ClipboardItem'a doğrudan promise veriliyor: önce çizip sonra yazmak
|
||||||
|
* kullanıcı jestinin süresini aşabiliyor. */
|
||||||
|
function copyImage(videoId, title, channel) {
|
||||||
|
if (typeof ClipboardItem === "undefined" || !navigator.clipboard.write) {
|
||||||
|
return Promise.reject(new Error("pano görsel desteklemiyor"));
|
||||||
|
}
|
||||||
|
return navigator.clipboard.write([
|
||||||
|
new ClipboardItem({ "image/png": compose(videoId, title, channel) })
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(videoId, title, isShort) {
|
||||||
|
var text = (title ? title + "\n" : "") + watchUrl(videoId, isShort);
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dosya adı başlıktan gelir; yalnız dosya sisteminin yasakladığı karakterler
|
||||||
|
* atılır, Türkçe harfler kalır. */
|
||||||
|
function fileName(title, videoId) {
|
||||||
|
var base = String(title || "")
|
||||||
|
.replace(/[/\\:*?"<>|\u0000-\u001f]/g, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim()
|
||||||
|
.slice(0, 120);
|
||||||
|
return (base || videoId) + ".png";
|
||||||
|
}
|
||||||
|
|
||||||
|
function download(videoId, title, channel) {
|
||||||
|
return compose(videoId, title, channel).then(function (blob) {
|
||||||
|
var url = URL.createObjectURL(blob);
|
||||||
|
var a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName(title, videoId);
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(function () { URL.revokeObjectURL(url); }, 10000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function save(entry) {
|
||||||
|
return OBStore.libraryAdd({
|
||||||
|
videoId: entry.videoId,
|
||||||
|
title: entry.title || "",
|
||||||
|
channel: entry.channel || "",
|
||||||
|
url: watchUrl(entry.videoId, entry.isShort),
|
||||||
|
isShort: !!entry.isShort,
|
||||||
|
score: entry.score != null ? entry.score : null,
|
||||||
|
views: entry.views != null ? entry.views : null,
|
||||||
|
savedAt: Date.now(),
|
||||||
|
/* Skorsuz kayıt (rozet daha yüklenirken menüden eklenen video) taze
|
||||||
|
* damgalanırsa kütüphanenin otomatik tazelemesi onu ttl boyunca atlar;
|
||||||
|
* damgasız kalsın ki ilk açılışta puanlansın. */
|
||||||
|
scoredAt: entry.score != null ? Date.now() : null,
|
||||||
|
projectId: entry.projectId != null ? entry.projectId : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
thumbnailUrl: thumbnailUrl,
|
||||||
|
watchUrl: watchUrl,
|
||||||
|
fetchThumbnail: fetchThumbnail,
|
||||||
|
compose: compose,
|
||||||
|
copyImage: copyImage,
|
||||||
|
copyText: copyText,
|
||||||
|
download: download,
|
||||||
|
save: save
|
||||||
|
};
|
||||||
|
})();
|
||||||
+76
-29
@@ -25,23 +25,6 @@ var OBBadge = (function () {
|
|||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isShortCard(card) {
|
|
||||||
return /shorts-lockup/i.test(card.tagName) || !!card.querySelector('a[href*="/shorts/"]');
|
|
||||||
}
|
|
||||||
|
|
||||||
function tone(score) {
|
|
||||||
if (score >= 10) return "ob-t4";
|
|
||||||
if (score >= 5) return "ob-t3";
|
|
||||||
if (score >= 2) return "ob-t2";
|
|
||||||
if (score >= 1) return "ob-t1";
|
|
||||||
return "ob-t0";
|
|
||||||
}
|
|
||||||
|
|
||||||
function label(score) {
|
|
||||||
if (score >= 10) return Math.round(score) + "x";
|
|
||||||
return T.num(Math.round(score * 10) / 10, 1) + "x";
|
|
||||||
}
|
|
||||||
|
|
||||||
function element(card) {
|
function element(card) {
|
||||||
var box = host(card);
|
var box = host(card);
|
||||||
var el = box.querySelector(":scope > .ob-badge");
|
var el = box.querySelector(":scope > .ob-badge");
|
||||||
@@ -50,30 +33,29 @@ var OBBadge = (function () {
|
|||||||
el.className = "ob-badge";
|
el.className = "ob-badge";
|
||||||
if (getComputedStyle(box).position === "static") box.style.position = "relative";
|
if (getComputedStyle(box).position === "static") box.style.position = "relative";
|
||||||
box.appendChild(el);
|
box.appendChild(el);
|
||||||
|
/* Tıklama dinleyicisi burada yok: rozet tıklamaları OBMenu window
|
||||||
|
* seviyesinde, capture aşamasında yakalar. Rozetin kendi üstündeki bir
|
||||||
|
* dinleyici Shorts kartlarında işe yaramıyordu, YouTube tıklamayı
|
||||||
|
* belge seviyesindeki capture dinleyicisiyle daha önce alıp Short'u
|
||||||
|
* açıyordu. */
|
||||||
}
|
}
|
||||||
/* Shorts kapağının altı izlenme metniyle dolu, rozet orada üste gider. */
|
|
||||||
el.dataset.short = isShortCard(card) ? "1" : "";
|
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
function base(el) {
|
|
||||||
return "ob-badge" + (el.dataset.short ? " ob-short" : "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function loading(card) {
|
function loading(card) {
|
||||||
var el = element(card);
|
var el = element(card);
|
||||||
el.className = base(el) + " ob-loading";
|
el.className = "ob-badge ob-loading";
|
||||||
el.textContent = "";
|
el.textContent = "";
|
||||||
el.removeAttribute("title");
|
el.removeAttribute("title");
|
||||||
}
|
}
|
||||||
|
|
||||||
function show(card, result, extra) {
|
function show(card, result, extra) {
|
||||||
var el = element(card);
|
var el = element(card);
|
||||||
el.className = base(el) + " " + tone(result.score);
|
el.className = "ob-badge " + P.scoreTone(result.score);
|
||||||
el.textContent = label(result.score);
|
el.textContent = P.scoreLabel(result.score);
|
||||||
var unit = T.t(extra && extra.isShort ? "unitShorts" : "unitVideos");
|
var unit = T.t(extra && extra.isShort ? "unitShorts" : "unitVideos");
|
||||||
var lines = [
|
var lines = [
|
||||||
T.t("badgeScore", label(result.score)),
|
T.t("badgeScore", P.scoreLabel(result.score)),
|
||||||
T.t("badgeViews", P.humanCount(result.views)),
|
T.t("badgeViews", P.humanCount(result.views)),
|
||||||
T.t("badgeMedian", P.humanCount(result.median), result.sample, unit)
|
T.t("badgeMedian", P.humanCount(result.median), result.sample, unit)
|
||||||
];
|
];
|
||||||
@@ -90,7 +72,7 @@ var OBBadge = (function () {
|
|||||||
|
|
||||||
function fail(card, reason) {
|
function fail(card, reason) {
|
||||||
var el = element(card);
|
var el = element(card);
|
||||||
el.className = base(el) + " ob-none";
|
el.className = "ob-badge ob-none";
|
||||||
el.textContent = "-";
|
el.textContent = "-";
|
||||||
el.title = reason || T.t("badgeFailed");
|
el.title = reason || T.t("badgeFailed");
|
||||||
}
|
}
|
||||||
@@ -101,5 +83,70 @@ var OBBadge = (function () {
|
|||||||
if (el) el.remove();
|
if (el) el.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
return { loading: loading, show: show, fail: fail, clear: clear };
|
/* Rozet hâlâ yerinde mi? Hover önizlemesi kapak kutusunu yeniden kurup
|
||||||
|
* rozeti silebiliyor; tarama bununla anlayıp geri basar. */
|
||||||
|
function present(card) {
|
||||||
|
return !!host(card).querySelector(":scope > .ob-badge");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Hayalet rozet ----------------------------------------------------
|
||||||
|
*
|
||||||
|
* Hover önizlemesi (ytd-video-preview) kartın üstüne uygulama seviyesinde
|
||||||
|
* ayrı bir katman bindiriyor; kartın içindeki rozet z-index'i ne olursa
|
||||||
|
* olsun o katmanın altında kalıyor. Çare: fare karta girince rozetin
|
||||||
|
* birebir kopyası body'ye fixed basılır. Body çocuğu olduğu ve z-index'i
|
||||||
|
* en yüksek olduğu için önizlemenin de üstünde durur, tıklanabilir kalır.
|
||||||
|
*
|
||||||
|
* Hayaletin ömrü DOM'la değil geometriyle yönetilir: önizleme katmanı
|
||||||
|
* kartın soyundan gelmediği için "fare hâlâ kartın içinde mi" sorusuna
|
||||||
|
* closest() cevap veremez; onun yerine imlecin kartın dikdörtgeni içinde
|
||||||
|
* olup olmadığına bakılır (önizleme karttan biraz taştığı için payla). */
|
||||||
|
var GHOST_MARGIN = 40;
|
||||||
|
var ghost = null;
|
||||||
|
var ghostCard = null;
|
||||||
|
|
||||||
|
function hideGhost() {
|
||||||
|
if (ghost) { ghost.remove(); ghost = null; ghostCard = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function showGhost(card) {
|
||||||
|
var el = host(card).querySelector(":scope > .ob-badge");
|
||||||
|
if (!el) { hideGhost(); return; }
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
if (!rect.width) { hideGhost(); return; }
|
||||||
|
if (!ghost) {
|
||||||
|
ghost = document.createElement("div");
|
||||||
|
ghost.dataset.obGhost = "1";
|
||||||
|
document.body.appendChild(ghost);
|
||||||
|
}
|
||||||
|
ghost.className = el.className + " ob-ghost";
|
||||||
|
ghost.textContent = el.textContent;
|
||||||
|
ghost.title = el.title || "";
|
||||||
|
ghost.obCard = card;
|
||||||
|
ghostCard = card;
|
||||||
|
ghost.style.left = rect.left + "px";
|
||||||
|
ghost.style.top = rect.top + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mouseover", function (e) {
|
||||||
|
var node = e.target;
|
||||||
|
if (node && node.dataset && node.dataset.obGhost) return;
|
||||||
|
while (node && !node.obVideoId) node = node.parentElement;
|
||||||
|
if (node && (node !== ghostCard || !ghost)) showGhost(node);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
document.addEventListener("mousemove", function (e) {
|
||||||
|
if (!ghost) return;
|
||||||
|
if (!ghostCard || !ghostCard.isConnected) { hideGhost(); return; }
|
||||||
|
var r = ghostCard.getBoundingClientRect();
|
||||||
|
if (e.clientX < r.left - GHOST_MARGIN || e.clientX > r.right + GHOST_MARGIN ||
|
||||||
|
e.clientY < r.top - GHOST_MARGIN || e.clientY > r.bottom + GHOST_MARGIN) {
|
||||||
|
hideGhost();
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener("scroll", hideGhost, true);
|
||||||
|
window.addEventListener("yt-navigate-start", hideGhost);
|
||||||
|
|
||||||
|
return { loading: loading, show: show, fail: fail, clear: clear, present: present };
|
||||||
})();
|
})();
|
||||||
|
|||||||
+74
-14
@@ -91,21 +91,47 @@
|
|||||||
|
|
||||||
/* --- Kanal kimliği --------------------------------------------------- */
|
/* --- Kanal kimliği --------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Kanal sayfasındaki kartlarda kanal bağlantısı hiç bulunmuyor: zaten o
|
||||||
|
* kanaldasın, YouTube adı kartta tekrar etmiyor. O kartların kanalı sayfanın
|
||||||
|
* kendi adresidir, video başına ayrı bir istek atmaya gerek yok. */
|
||||||
|
function pageChannel() {
|
||||||
|
var path = location.pathname;
|
||||||
|
var m = /^\/channel\/(UC[\w-]{22})(?:\/|$)/.exec(path);
|
||||||
|
if (m) return { channelId: m[1] };
|
||||||
|
m = /^\/(@[\w.\-]+)(?:\/|$)/.exec(path);
|
||||||
|
if (m) return { handle: m[1] };
|
||||||
|
/* Eski /c/ ve /user/ adresleri: çözüm için tam adres gerekiyor. */
|
||||||
|
m = /^\/((?:c|user)\/[^/]+)(?:\/|$)/.exec(path);
|
||||||
|
if (m) return { handle: location.origin + "/" + m[1] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/* Handle'dan kanal kimliğine çözüm kalıcıdır (değişmez), o yüzden TTL'siz
|
/* Handle'dan kanal kimliğine çözüm kalıcıdır (değişmez), o yüzden TTL'siz
|
||||||
* saklanır. İzleme sayfasının yan listesinde kartta kanal bağlantısı hiç
|
* saklanır. Arama sonuçlarında aynı kanaldan onlarca kart aynı anda ekrana
|
||||||
|
* giriyor ve depo okuması asenkron olduğu için hepsi önbelleği ıskalayıp
|
||||||
|
* aynı çözümü ayrı ayrı isterdi; devam eden istek paylaşılıyor. */
|
||||||
|
var resolving = {};
|
||||||
|
|
||||||
|
function resolveHandle(handle) {
|
||||||
|
if (resolving[handle]) return resolving[handle];
|
||||||
|
var promise = OBStore.readChannelId(handle).then(function (cached) {
|
||||||
|
if (cached) return cached;
|
||||||
|
return OBTube.resolveChannel(handle).then(function (id) {
|
||||||
|
OBStore.writeChannelId(handle, id);
|
||||||
|
return id;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
resolving[handle] = promise;
|
||||||
|
promise.catch(function () {}).then(function () { delete resolving[handle]; });
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* İzleme sayfasının yan listesinde kartta kanal bağlantısı hiç
|
||||||
* olmayabiliyor; orada son çare videonun kendi ucundan sorulur. */
|
* olmayabiliyor; orada son çare videonun kendi ucundan sorulur. */
|
||||||
function channelIdOf(card, videoId) {
|
function channelIdOf(card, videoId) {
|
||||||
var found = handleOf(card);
|
var found = handleOf(card) || pageChannel();
|
||||||
if (found && found.channelId) return Promise.resolve(found.channelId);
|
if (found && found.channelId) return Promise.resolve(found.channelId);
|
||||||
if (found && found.handle) {
|
if (found && found.handle) return resolveHandle(found.handle);
|
||||||
return OBStore.readChannelId(found.handle).then(function (cached) {
|
|
||||||
if (cached) return cached;
|
|
||||||
return OBTube.resolveChannel(found.handle).then(function (id) {
|
|
||||||
OBStore.writeChannelId(found.handle, id);
|
|
||||||
return id;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var key = "v:" + videoId;
|
var key = "v:" + videoId;
|
||||||
return OBStore.readChannelId(key).then(function (cached) {
|
return OBStore.readChannelId(key).then(function (cached) {
|
||||||
if (cached) return cached;
|
if (cached) return cached;
|
||||||
@@ -120,12 +146,21 @@
|
|||||||
|
|
||||||
function processCard(card) {
|
function processCard(card) {
|
||||||
var videoId = videoIdOf(card);
|
var videoId = videoIdOf(card);
|
||||||
if (!videoId) return;
|
/* Erken dönüşlerin hepsi obBadged'i düşürmek zorunda: bayrak yüksek ama
|
||||||
|
* rozet yok kalırsa scan()'in geri basma dalı kartı her turda yeniden
|
||||||
|
* işleyen bir döngüye girer. */
|
||||||
|
if (!videoId) { card.obBadged = false; return; }
|
||||||
if (card.obVideoId === videoId) return;
|
if (card.obVideoId === videoId) return;
|
||||||
card.obVideoId = videoId;
|
card.obVideoId = videoId;
|
||||||
|
card.obScore = null;
|
||||||
|
card.obViews = null;
|
||||||
|
|
||||||
var isShort = isShortCard(card);
|
var isShort = isShortCard(card);
|
||||||
if (isShort && !settings.scoreShorts) { OBBadge.clear(card); return; }
|
if (isShort && !settings.scoreShorts) {
|
||||||
|
card.obBadged = false;
|
||||||
|
OBBadge.clear(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
counters.seen++;
|
counters.seen++;
|
||||||
OBBadge.loading(card);
|
OBBadge.loading(card);
|
||||||
@@ -140,19 +175,26 @@
|
|||||||
var result = OBScore.score(videoId, views, isShort, blob);
|
var result = OBScore.score(videoId, views, isShort, blob);
|
||||||
if (result.score == null) {
|
if (result.score == null) {
|
||||||
counters.skipped++;
|
counters.skipped++;
|
||||||
|
card.obBadged = false;
|
||||||
OBBadge.clear(card);
|
OBBadge.clear(card);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (settings.minScore && result.score < settings.minScore) {
|
if (settings.minScore && result.score < settings.minScore) {
|
||||||
|
card.obBadged = false;
|
||||||
OBBadge.clear(card);
|
OBBadge.clear(card);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var age = own && own.age != null ? own.age : domAgeDays(card);
|
var age = own && own.age != null ? own.age : domAgeDays(card);
|
||||||
|
/* Rozet menüsü kaydederken skoru da yazsın diye kartta saklanır. */
|
||||||
|
card.obScore = result.score;
|
||||||
|
card.obViews = result.views;
|
||||||
counters.scored++;
|
counters.scored++;
|
||||||
|
card.obBadged = true;
|
||||||
OBBadge.show(card, result, { young: age != null && age < 7, isShort: isShort });
|
OBBadge.show(card, result, { young: age != null && age < 7, isShort: isShort });
|
||||||
}).catch(function (err) {
|
}).catch(function (err) {
|
||||||
counters.failed++;
|
counters.failed++;
|
||||||
lastError = String(err && err.message ? err.message : err);
|
lastError = String(err && err.message ? err.message : err);
|
||||||
|
card.obBadged = false;
|
||||||
OBBadge.clear(card);
|
OBBadge.clear(card);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -166,6 +208,16 @@
|
|||||||
return m ? m[1] : null;
|
return m ? m[1] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Videonun süresi InnerTube'un next yanıtında yok, sayfanın oynatıcısından
|
||||||
|
* okunuyor. Okunamazsa normal video sayılır: /watch adresinde açılan bir
|
||||||
|
* Short nadir, yanlış havuzda puanlamaktansa gecikmeli doğruyu beklemek
|
||||||
|
* anlamsız olurdu. */
|
||||||
|
function watchIsShort() {
|
||||||
|
if (location.pathname.indexOf("/shorts/") === 0) return true;
|
||||||
|
var v = document.querySelector("#movie_player video");
|
||||||
|
return !!(v && isFinite(v.duration) && v.duration > 0 && v.duration <= 60);
|
||||||
|
}
|
||||||
|
|
||||||
function updatePanel() {
|
function updatePanel() {
|
||||||
if (!settings || !settings.enabled || !settings.showPanel) { OBPanel.remove(); return; }
|
if (!settings || !settings.enabled || !settings.showPanel) { OBPanel.remove(); return; }
|
||||||
var videoId = currentWatchId();
|
var videoId = currentWatchId();
|
||||||
@@ -179,7 +231,7 @@
|
|||||||
details = d;
|
details = d;
|
||||||
if (!d.channelId) throw new Error("kanal bulunamadı");
|
if (!d.channelId) throw new Error("kanal bulunamadı");
|
||||||
OBStore.writeChannelId("v:" + videoId, d.channelId);
|
OBStore.writeChannelId("v:" + videoId, d.channelId);
|
||||||
var isShort = d.lengthSeconds != null && d.lengthSeconds <= 60;
|
var isShort = watchIsShort();
|
||||||
return OBScore.baseline(d.channelId, isShort).then(function (blob) {
|
return OBScore.baseline(d.channelId, isShort).then(function (blob) {
|
||||||
return { blob: blob, isShort: isShort };
|
return { blob: blob, isShort: isShort };
|
||||||
});
|
});
|
||||||
@@ -225,6 +277,13 @@
|
|||||||
/* YouTube kartları geri dönüştürüyor: aynı DOM elemanı başka videoya
|
/* YouTube kartları geri dönüştürüyor: aynı DOM elemanı başka videoya
|
||||||
* bağlıysa rozet eskimiş demektir. */
|
* bağlıysa rozet eskimiş demektir. */
|
||||||
processCard(card);
|
processCard(card);
|
||||||
|
} else if (card.obBadged && !OBBadge.present(card)) {
|
||||||
|
/* Hover önizlemesi (özellikle Shorts'ta) kapak kutusunu yeniden
|
||||||
|
* kuruyor ve rozet DOM'dan siliniyor. Baseline önbellekte, geri
|
||||||
|
* basmak ucuz. obVideoId sıfırlanır ki processCard "zaten işledim"
|
||||||
|
* deyip erken dönmesin. */
|
||||||
|
card.obVideoId = null;
|
||||||
|
processCard(card);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,6 +299,7 @@
|
|||||||
for (var i = 0; i < cards.length; i++) {
|
for (var i = 0; i < cards.length; i++) {
|
||||||
OBBadge.clear(cards[i]);
|
OBBadge.clear(cards[i]);
|
||||||
cards[i].obVideoId = null;
|
cards[i].obVideoId = null;
|
||||||
|
cards[i].obBadged = false;
|
||||||
}
|
}
|
||||||
OBPanel.remove();
|
OBPanel.remove();
|
||||||
panelVideoId = null;
|
panelVideoId = null;
|
||||||
|
|||||||
+74
@@ -21,6 +21,42 @@ var OBI18n = (function () {
|
|||||||
unitVideos: "video",
|
unitVideos: "video",
|
||||||
unitShorts: "Shorts",
|
unitShorts: "Shorts",
|
||||||
|
|
||||||
|
menuCopyImage: "Görseli kopyala",
|
||||||
|
menuCopyText: "Başlık + linki kopyala",
|
||||||
|
menuCopied: "Kopyalandı ✓",
|
||||||
|
menuDownload: "Kapak + başlığı indir",
|
||||||
|
menuDownloaded: "İndirildi ✓",
|
||||||
|
menuSave: "Kütüphaneye ekle",
|
||||||
|
menuSaved: "Eklendi ✓",
|
||||||
|
menuUnsave: "Kütüphaneden çıkar",
|
||||||
|
menuRemoved: "Çıkarıldı ✓",
|
||||||
|
menuFailed: "Olmadı, tekrar dene",
|
||||||
|
|
||||||
|
libTitle: "Kütüphane",
|
||||||
|
libAll: "Tümü",
|
||||||
|
libInbox: "Gelen kutusu",
|
||||||
|
libNewProject: "+ Yeni proje",
|
||||||
|
libProjectName: "Proje adı",
|
||||||
|
libRenameProject: "Yeniden adlandır",
|
||||||
|
libDeleteProject: "Projeyi sil",
|
||||||
|
libDeleteProjectHint: "Silinen projenin videoları gelen kutusuna taşınır",
|
||||||
|
libSearch: "Başlık veya kanal ara",
|
||||||
|
libRefresh: "Skorları güncelle",
|
||||||
|
libRefreshing: "Skorlar güncelleniyor ($1 video)…",
|
||||||
|
libRefreshed: "$1 videonun skoru güncellendi.",
|
||||||
|
libExport: "Dışa aktar",
|
||||||
|
libImport: "İçe aktar",
|
||||||
|
libImported: "$1 video, $2 proje içe aktarıldı.",
|
||||||
|
libImportFailed: "Dosya okunamadı: $1",
|
||||||
|
libEmpty: "Burada henüz video yok. YouTube'da bir rozete tıklayıp \"Kütüphaneye ekle\" de.",
|
||||||
|
libCount: "$1 video",
|
||||||
|
libCopyImage: "Görsel",
|
||||||
|
libCopyText: "Metin",
|
||||||
|
libDownload: "İndir",
|
||||||
|
libDelete: "Sil",
|
||||||
|
libConfirmDelete: "Silinsin?",
|
||||||
|
libDone: "✓",
|
||||||
|
|
||||||
panelLoading: "Outlier hesaplanıyor",
|
panelLoading: "Outlier hesaplanıyor",
|
||||||
panelTitle: "Outlier",
|
panelTitle: "Outlier",
|
||||||
panelHead: "kanal medyanının katı",
|
panelHead: "kanal medyanının katı",
|
||||||
@@ -52,6 +88,7 @@ var OBI18n = (function () {
|
|||||||
popupMinScore: "Şu skorun altını gizle",
|
popupMinScore: "Şu skorun altını gizle",
|
||||||
popupTtl: "Önbellek ömrü",
|
popupTtl: "Önbellek ömrü",
|
||||||
popupLanguage: "Dil",
|
popupLanguage: "Dil",
|
||||||
|
popupLibrary: "Kütüphaneyi aç",
|
||||||
popupClear: "Önbelleği temizle",
|
popupClear: "Önbelleği temizle",
|
||||||
popupCleared: "Önbellek temizlendi.",
|
popupCleared: "Önbellek temizlendi.",
|
||||||
popupNote: "Skor = videonun izlenmesi / kanalın son N videosunun izlenme medyanı. " +
|
popupNote: "Skor = videonun izlenmesi / kanalın son N videosunun izlenme medyanı. " +
|
||||||
@@ -84,6 +121,42 @@ var OBI18n = (function () {
|
|||||||
unitVideos: "videos",
|
unitVideos: "videos",
|
||||||
unitShorts: "Shorts",
|
unitShorts: "Shorts",
|
||||||
|
|
||||||
|
menuCopyImage: "Copy image",
|
||||||
|
menuCopyText: "Copy title + link",
|
||||||
|
menuCopied: "Copied ✓",
|
||||||
|
menuDownload: "Download thumbnail + title",
|
||||||
|
menuDownloaded: "Downloaded ✓",
|
||||||
|
menuSave: "Add to library",
|
||||||
|
menuSaved: "Added ✓",
|
||||||
|
menuUnsave: "Remove from library",
|
||||||
|
menuRemoved: "Removed ✓",
|
||||||
|
menuFailed: "Failed, try again",
|
||||||
|
|
||||||
|
libTitle: "Library",
|
||||||
|
libAll: "All",
|
||||||
|
libInbox: "Inbox",
|
||||||
|
libNewProject: "+ New project",
|
||||||
|
libProjectName: "Project name",
|
||||||
|
libRenameProject: "Rename",
|
||||||
|
libDeleteProject: "Delete project",
|
||||||
|
libDeleteProjectHint: "A deleted project's videos move to the inbox",
|
||||||
|
libSearch: "Search title or channel",
|
||||||
|
libRefresh: "Refresh scores",
|
||||||
|
libRefreshing: "Updating scores ($1 videos)…",
|
||||||
|
libRefreshed: "Updated scores for $1 videos.",
|
||||||
|
libExport: "Export",
|
||||||
|
libImport: "Import",
|
||||||
|
libImported: "Imported $1 videos and $2 projects.",
|
||||||
|
libImportFailed: "Could not read the file: $1",
|
||||||
|
libEmpty: "Nothing here yet. Click a badge on YouTube and pick \"Add to library\".",
|
||||||
|
libCount: "$1 videos",
|
||||||
|
libCopyImage: "Image",
|
||||||
|
libCopyText: "Text",
|
||||||
|
libDownload: "Download",
|
||||||
|
libDelete: "Delete",
|
||||||
|
libConfirmDelete: "Delete?",
|
||||||
|
libDone: "✓",
|
||||||
|
|
||||||
panelLoading: "Calculating outlier score",
|
panelLoading: "Calculating outlier score",
|
||||||
panelTitle: "Outlier",
|
panelTitle: "Outlier",
|
||||||
panelHead: "of the channel median",
|
panelHead: "of the channel median",
|
||||||
@@ -115,6 +188,7 @@ var OBI18n = (function () {
|
|||||||
popupMinScore: "Hide scores below",
|
popupMinScore: "Hide scores below",
|
||||||
popupTtl: "Cache lifetime",
|
popupTtl: "Cache lifetime",
|
||||||
popupLanguage: "Language",
|
popupLanguage: "Language",
|
||||||
|
popupLibrary: "Open library",
|
||||||
popupClear: "Clear cache",
|
popupClear: "Clear cache",
|
||||||
popupCleared: "Cache cleared.",
|
popupCleared: "Cache cleared.",
|
||||||
popupNote: "Score = the video's views divided by the median views of the channel's last N " +
|
popupNote: "Score = the video's views divided by the median views of the channel's last N " +
|
||||||
|
|||||||
+56
-27
@@ -268,45 +268,74 @@ var OBTube = (function () {
|
|||||||
return post("browse", { browseId: channelId, params: params }).then(round);
|
return post("browse", { browseId: channelId, params: params }).then(round);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* @handle veya kanal adresini UC... kimliğine çevirir. */
|
function channelIdIn(url) {
|
||||||
function resolveChannel(raw) {
|
if (!url || url.indexOf("/channel/") < 0) return null;
|
||||||
raw = (raw || "").trim();
|
var tail = url.split("/channel/")[1].split("/")[0].split("?")[0];
|
||||||
if (!raw) return Promise.reject(new Error("boş kanal adresi"));
|
return /^UC[\w-]{22}$/.test(tail) ? tail : null;
|
||||||
if (/^UC[\w-]{22}$/.test(raw)) return Promise.resolve(raw);
|
}
|
||||||
if (raw.indexOf("/channel/") >= 0) {
|
|
||||||
var tail = raw.split("/channel/")[1].split("/")[0].split("?")[0];
|
/* Tek istekte bitmeyebilir: eski özel adresi olan kanallarda YouTube
|
||||||
if (tail.indexOf("UC") === 0) return Promise.resolve(tail);
|
* @handle'ı önce youtube.com/eskiad adresine yolluyor, kanal kimliği ancak
|
||||||
}
|
* bir sonraki adımda geliyor. Zincir kısa, sıçrama sayısı sınırlı: hem
|
||||||
var url = raw;
|
* döngüye girmeyelim hem de tek kart için istek yağdırmayalım. */
|
||||||
if (url.indexOf("http") !== 0) {
|
var MAX_RESOLVE_HOPS = 3;
|
||||||
url = "https://www.youtube.com/" + (url.charAt(0) === "@" ? url : "@" + url.replace(/^\/+/, ""));
|
|
||||||
}
|
function resolveStep(url, raw, hops) {
|
||||||
return post("navigation/resolve_url", { url: url }).then(function (data) {
|
return post("navigation/resolve_url", { url: url }).then(function (data) {
|
||||||
var endpoint = P.findFirst(data, "browseEndpoint") || {};
|
var endpoint = P.findFirst(data, "browseEndpoint") || {};
|
||||||
var id = endpoint.browseId || "";
|
var id = endpoint.browseId || "";
|
||||||
if (id.indexOf("UC") === 0) return id;
|
if (/^UC[\w-]{22}$/.test(id)) return id;
|
||||||
/* Eski /user/ adresi olan kanallarda YouTube browseEndpoint yerine
|
|
||||||
* urlEndpoint ile başka bir adrese yolluyor. */
|
|
||||||
var next = (P.findFirst(data, "urlEndpoint") || {}).url || "";
|
var next = (P.findFirst(data, "urlEndpoint") || {}).url || "";
|
||||||
if (next.indexOf("/channel/") >= 0) {
|
var direct = channelIdIn(next);
|
||||||
var t = next.split("/channel/")[1].split("/")[0].split("?")[0];
|
if (direct) return direct;
|
||||||
if (t.indexOf("UC") === 0) return t;
|
/* Sadece YouTube içinde kalan yönlendirmeler izlenir. */
|
||||||
|
var internal = /^https?:\/\/(www\.)?youtube\.com\//.test(next);
|
||||||
|
if (internal && next !== url && hops < MAX_RESOLVE_HOPS) {
|
||||||
|
return resolveStep(next, raw, hops + 1);
|
||||||
}
|
}
|
||||||
throw new Error("kanal çözülemedi: " + raw);
|
throw new Error("kanal çözülemedi: " + raw);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* @handle veya kanal adresini UC... kimliğine çevirir. */
|
||||||
|
function resolveChannel(raw) {
|
||||||
|
raw = (raw || "").trim();
|
||||||
|
if (!raw) return Promise.reject(new Error("boş kanal adresi"));
|
||||||
|
if (/^UC[\w-]{22}$/.test(raw)) return Promise.resolve(raw);
|
||||||
|
var direct = channelIdIn(raw);
|
||||||
|
if (direct) return Promise.resolve(direct);
|
||||||
|
var url = raw;
|
||||||
|
if (url.indexOf("http") !== 0) {
|
||||||
|
url = "https://www.youtube.com/" + (url.charAt(0) === "@" ? url : "@" + url.replace(/^\/+/, ""));
|
||||||
|
}
|
||||||
|
return resolveStep(url, raw, 0);
|
||||||
|
}
|
||||||
|
|
||||||
/* Videonun kanal kimliği ve tam izlenme sayısı. Kartta kanal bağlantısı
|
/* Videonun kanal kimliği ve tam izlenme sayısı. Kartta kanal bağlantısı
|
||||||
* bulunamadığında son çare olarak kullanılır: video başına bir istek. */
|
* bulunamadığında son çare olarak kullanılır: video başına bir istek.
|
||||||
|
*
|
||||||
|
* Buna `player` ucu daha uygun görünür ve bir zamanlar öyleydi; oturum
|
||||||
|
* bilgisi göndermediğimiz için artık videoların büyük çoğunluğunda
|
||||||
|
* LOGIN_REQUIRED ("Sign in to confirm you're not a bot") dönüyor ve
|
||||||
|
* videoDetails hiç gelmiyor. `next` aynı iki alanı oturumsuz da veriyor.
|
||||||
|
* Yanıt büyük olduğu için bu uç bilerek son çare, ilk seçenek değil. */
|
||||||
function videoDetails(videoId) {
|
function videoDetails(videoId) {
|
||||||
return post("player", { videoId: videoId }).then(function (data) {
|
return post("next", { videoId: videoId }).then(function (data) {
|
||||||
var d = data.videoDetails || {};
|
var owner = P.findFirst(data, "videoOwnerRenderer") || {};
|
||||||
|
var endpoint = P.findFirst(owner, "browseEndpoint") || {};
|
||||||
|
var channelId = endpoint.browseId || "";
|
||||||
|
var counter = P.findFirst(data, "videoViewCountRenderer") || {};
|
||||||
|
/* viewCount tam sayıyı verir ("63,087 views"); yoksa kısaltılmışa düş.
|
||||||
|
* Süren canlı yayında aynı alan "12,345 watching now" der; bu izlenme
|
||||||
|
* değil anlık izleyicidir, sayı sanılırsa skor uydurma çıkar. */
|
||||||
|
var countText = P.textOf(counter.viewCount) || P.textOf(counter.shortViewCount);
|
||||||
|
var views = /watching/i.test(countText) ? null : P.parseCountEn(countText);
|
||||||
|
var primary = P.findFirst(data, "videoPrimaryInfoRenderer") || {};
|
||||||
return {
|
return {
|
||||||
channelId: d.channelId || null,
|
channelId: /^UC[\w-]{22}$/.test(channelId) ? channelId : null,
|
||||||
views: d.viewCount ? parseInt(d.viewCount, 10) : null,
|
views: views,
|
||||||
title: d.title || "",
|
title: P.textOf(primary.title),
|
||||||
lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null,
|
publishedText: P.textOf(primary.dateText)
|
||||||
isLive: !!d.isLiveContent
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
/* Rozete tıklanınca açılan menü: kopyala, indir, kütüphaneye ekle/çıkar.
|
||||||
|
*
|
||||||
|
* Menü kartın içine değil body'ye eklenir: kart kapsayıcılarının overflow'u
|
||||||
|
* menüyü kırpar. Konum rozetin o anki yerinden ölçülür, kaydırma ve sayfa içi
|
||||||
|
* gezinme menüyü kapatır; takip etmeye çalışmaktan ucuz ve yeterli.
|
||||||
|
*/
|
||||||
|
var OBMenu = (function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var T = OBI18n;
|
||||||
|
var open = null;
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (open) { open.remove(); open = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Rozetin kendisi hariç tutulur: rozet tıklaması buraya da düşer ve menü
|
||||||
|
* burada kapatılırsa toggle açık menü göremez, ikinci tık kapatmak yerine
|
||||||
|
* yeniden açardı. */
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
if (!open) return;
|
||||||
|
if (open.contains(e.target) || open.obAnchor.contains(e.target)) return;
|
||||||
|
close();
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
/* Rozet tıklamaları window seviyesinde, capture aşamasında yakalanır ve
|
||||||
|
* yutulur. Rozetin kendi üstünde dinleyici yetmiyordu: YouTube özellikle
|
||||||
|
* Shorts kartlarında tıklamayı belge seviyesindeki capture dinleyicisiyle
|
||||||
|
* bizden önce alıp Short'u açıyordu. Olay yolu window -> document diye
|
||||||
|
* aktığı için buradaki stopPropagation YouTube'unkilerden önce çalışır. */
|
||||||
|
var badgeOf = function (e) {
|
||||||
|
return e.target && e.target.closest ? e.target.closest(".ob-badge") : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
["pointerdown", "pointerup", "mousedown", "mouseup", "touchend"].forEach(function (type) {
|
||||||
|
window.addEventListener(type, function (e) {
|
||||||
|
if (badgeOf(e)) e.stopPropagation();
|
||||||
|
}, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("click", function (e) {
|
||||||
|
var badge = badgeOf(e);
|
||||||
|
if (!badge) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
/* Hayalet rozet body'de durur, kartı DOM'dan bulunamaz; kart referansı
|
||||||
|
* hayaletin üstünde taşınır. */
|
||||||
|
var card = badge.obCard || badge.parentElement;
|
||||||
|
while (card && !card.obVideoId) card = card.parentElement;
|
||||||
|
if (card) toggle(badge, contextOf(card));
|
||||||
|
}, true);
|
||||||
|
window.addEventListener("scroll", close, true);
|
||||||
|
window.addEventListener("yt-navigate-start", close);
|
||||||
|
window.addEventListener("resize", close);
|
||||||
|
|
||||||
|
/* --- Karttan bağlam çıkarma ------------------------------------------ */
|
||||||
|
|
||||||
|
/* Başlık kart türüne göre farklı yerlerde; sıra özelden genele. */
|
||||||
|
var TITLE_SELECTORS = [
|
||||||
|
"#video-title",
|
||||||
|
"a#video-title-link",
|
||||||
|
"[class*='lockup-metadata'][class*='title']",
|
||||||
|
".shortsLockupViewModelHostMetadataTitle",
|
||||||
|
"h3 a[href*='/watch']",
|
||||||
|
"h3 a[href*='/shorts/']"
|
||||||
|
];
|
||||||
|
|
||||||
|
function titleOf(card) {
|
||||||
|
for (var i = 0; i < TITLE_SELECTORS.length; i++) {
|
||||||
|
var el = card.querySelector(TITLE_SELECTORS[i]);
|
||||||
|
var text = el && ((el.getAttribute("title") || "").trim() || (el.textContent || "").trim());
|
||||||
|
if (text) return text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
var CHANNEL_SELECTORS = [
|
||||||
|
"ytd-channel-name a",
|
||||||
|
"ytd-channel-name #text",
|
||||||
|
"[class*='metadata-view-model'][class*='text'] a[href^='/@']",
|
||||||
|
"a[href^='/@']"
|
||||||
|
];
|
||||||
|
|
||||||
|
function channelOf(card) {
|
||||||
|
for (var i = 0; i < CHANNEL_SELECTORS.length; i++) {
|
||||||
|
var el = card.querySelector(CHANNEL_SELECTORS[i]);
|
||||||
|
var text = el && (el.textContent || "").trim();
|
||||||
|
if (text && text.charAt(0) !== "/") return text;
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function contextOf(card) {
|
||||||
|
return {
|
||||||
|
videoId: card.obVideoId,
|
||||||
|
title: titleOf(card),
|
||||||
|
channel: channelOf(card),
|
||||||
|
isShort: /shorts-lockup/i.test(card.tagName) || !!card.querySelector('a[href*="/shorts/"]'),
|
||||||
|
score: card.obScore != null ? card.obScore : null,
|
||||||
|
views: card.obViews != null ? card.obViews : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kanal sayfası kartlarında başlık bazen sadece görselin aria-label'ında;
|
||||||
|
* hiçbir yerde yoksa son çare InnerTube'a sorulur (tek istek, menü zaten
|
||||||
|
* kullanıcı jestiyle açıldı). */
|
||||||
|
function ensureTitle(ctx) {
|
||||||
|
if (ctx.title) return Promise.resolve(ctx);
|
||||||
|
return OBTube.videoDetails(ctx.videoId).then(function (d) {
|
||||||
|
ctx.title = d.title || "";
|
||||||
|
return ctx;
|
||||||
|
}, function () { return ctx; });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Menü ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
function item(labelKey, action) {
|
||||||
|
var el = document.createElement("button");
|
||||||
|
el.type = "button";
|
||||||
|
el.className = "ob-menu-item";
|
||||||
|
el.textContent = T.t(labelKey);
|
||||||
|
el.addEventListener("click", function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
if (el.disabled) return;
|
||||||
|
el.disabled = true;
|
||||||
|
el.textContent = "…";
|
||||||
|
action().then(function (doneKey) {
|
||||||
|
el.textContent = T.t(doneKey);
|
||||||
|
/* Zamanlayıcı yalnız kendi menüsünü kapatır: aradan geçen sürede
|
||||||
|
* başka bir rozetin menüsü açıldıysa ona dokunmaz. */
|
||||||
|
var owner = el.parentElement;
|
||||||
|
setTimeout(function () { if (open === owner) close(); }, 900);
|
||||||
|
}, function () {
|
||||||
|
el.disabled = false;
|
||||||
|
el.textContent = T.t("menuFailed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
function place(menu, anchor) {
|
||||||
|
var rect = anchor.getBoundingClientRect();
|
||||||
|
menu.style.visibility = "hidden";
|
||||||
|
document.body.appendChild(menu);
|
||||||
|
var width = menu.offsetWidth;
|
||||||
|
var height = menu.offsetHeight;
|
||||||
|
var left = Math.min(rect.right - width, window.innerWidth - width - 8);
|
||||||
|
var top = rect.bottom + 6;
|
||||||
|
if (top + height > window.innerHeight - 8) top = rect.top - height - 6;
|
||||||
|
menu.style.left = Math.max(8, left) + "px";
|
||||||
|
menu.style.top = Math.max(8, top) + "px";
|
||||||
|
menu.style.visibility = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggle(anchor, ctx) {
|
||||||
|
if (open && open.obAnchor === anchor) { close(); return; }
|
||||||
|
close();
|
||||||
|
if (!ctx.videoId) return;
|
||||||
|
|
||||||
|
var menu = document.createElement("div");
|
||||||
|
menu.className = "ob-menu";
|
||||||
|
menu.obAnchor = anchor;
|
||||||
|
|
||||||
|
menu.appendChild(item("menuCopyImage", function () {
|
||||||
|
return ensureTitle(ctx).then(function () {
|
||||||
|
return OBActions.copyImage(ctx.videoId, ctx.title, ctx.channel);
|
||||||
|
}).then(function () { return "menuCopied"; });
|
||||||
|
}));
|
||||||
|
|
||||||
|
menu.appendChild(item("menuCopyText", function () {
|
||||||
|
return ensureTitle(ctx).then(function () {
|
||||||
|
return OBActions.copyText(ctx.videoId, ctx.title, ctx.isShort);
|
||||||
|
}).then(function () { return "menuCopied"; });
|
||||||
|
}));
|
||||||
|
|
||||||
|
menu.appendChild(item("menuDownload", function () {
|
||||||
|
return ensureTitle(ctx).then(function () {
|
||||||
|
return OBActions.download(ctx.videoId, ctx.title, ctx.channel);
|
||||||
|
}).then(function () { return "menuDownloaded"; });
|
||||||
|
}));
|
||||||
|
|
||||||
|
var saved = false;
|
||||||
|
var saveItem = item("menuSave", function () {
|
||||||
|
if (saved) {
|
||||||
|
return OBStore.libraryRemove(ctx.videoId).then(function () {
|
||||||
|
saved = false;
|
||||||
|
return "menuRemoved";
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ensureTitle(ctx).then(function () {
|
||||||
|
return OBActions.save(ctx);
|
||||||
|
}).then(function () {
|
||||||
|
saved = true;
|
||||||
|
return "menuSaved";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
menu.appendChild(saveItem);
|
||||||
|
OBStore.libraryHas(ctx.videoId).then(function (has) {
|
||||||
|
if (has && !saveItem.disabled) {
|
||||||
|
saved = true;
|
||||||
|
saveItem.textContent = T.t("menuUnsave");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
place(menu, anchor);
|
||||||
|
open = menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { toggle: toggle, contextOf: contextOf, close: close };
|
||||||
|
})();
|
||||||
+1
-9
@@ -139,21 +139,13 @@ var OBPanel = (function () {
|
|||||||
panel.appendChild(el("div", "ob-note", message));
|
panel.appendChild(el("div", "ob-note", message));
|
||||||
}
|
}
|
||||||
|
|
||||||
function tone(score) {
|
|
||||||
return score >= 10 ? "ob-t4" : score >= 5 ? "ob-t3"
|
|
||||||
: score >= 2 ? "ob-t2" : score >= 1 ? "ob-t1" : "ob-t0";
|
|
||||||
}
|
|
||||||
|
|
||||||
function render(data) {
|
function render(data) {
|
||||||
var panel = mount();
|
var panel = mount();
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
panel.textContent = "";
|
panel.textContent = "";
|
||||||
|
|
||||||
var head = el("div", "ob-panel-head");
|
var head = el("div", "ob-panel-head");
|
||||||
var scoreText = data.score >= 10
|
head.appendChild(el("span", "ob-chip " + P.scoreTone(data.score), P.scoreLabel(data.score)));
|
||||||
? Math.round(data.score) + "x"
|
|
||||||
: T.num(Math.round(data.score * 10) / 10, 1) + "x";
|
|
||||||
head.appendChild(el("span", "ob-chip " + tone(data.score), scoreText));
|
|
||||||
head.appendChild(el("span", "ob-head-text", T.t("panelHead")));
|
head.appendChild(el("span", "ob-head-text", T.t("panelHead")));
|
||||||
panel.appendChild(head);
|
panel.appendChild(head);
|
||||||
|
|
||||||
|
|||||||
@@ -133,9 +133,27 @@ var OBParse = (function () {
|
|||||||
return String(Math.round(n));
|
return String(Math.round(n));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skor -> renk sınıfı ve "2,4x" etiketi. Rozet, izleme paneli ve kütüphane
|
||||||
|
* aynı eşikleri ve yuvarlamayı kullanır; tek kopya burada durur ki eşik
|
||||||
|
* değişince üçü birden değişsin. */
|
||||||
|
function scoreTone(score) {
|
||||||
|
if (score == null) return null;
|
||||||
|
return score >= 10 ? "ob-t4" : score >= 5 ? "ob-t3"
|
||||||
|
: score >= 2 ? "ob-t2" : score >= 1 ? "ob-t1" : "ob-t0";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreLabel(score) {
|
||||||
|
if (score >= 10) return Math.round(score) + "x";
|
||||||
|
var rounded = Math.round(score * 10) / 10;
|
||||||
|
if (typeof OBI18n === "undefined") return rounded.toFixed(1) + "x";
|
||||||
|
return OBI18n.num(rounded, 1) + "x";
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
findAll: findAll,
|
findAll: findAll,
|
||||||
findFirst: findFirst,
|
findFirst: findFirst,
|
||||||
|
scoreTone: scoreTone,
|
||||||
|
scoreLabel: scoreLabel,
|
||||||
textOf: textOf,
|
textOf: textOf,
|
||||||
parseCountEn: parseCountEn,
|
parseCountEn: parseCountEn,
|
||||||
parseCountUI: parseCountUI,
|
parseCountUI: parseCountUI,
|
||||||
|
|||||||
@@ -116,6 +116,58 @@ var OBStore = (function () {
|
|||||||
return set(payload);
|
return set(payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Kütüphane ------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Kütüphane bellekte önbelleklenmez: kütüphane sayfası ile açık YouTube
|
||||||
|
* sekmeleri aynı veriyi düzenliyor, bayat kopya yanlış "ekli/değil" gösterir.
|
||||||
|
* Erişim seyrek (menü açılışı, kayıt), her seferinde depodan okumak ucuz. */
|
||||||
|
var LIBRARY_KEY = "library";
|
||||||
|
|
||||||
|
function readLibrary() {
|
||||||
|
return get(LIBRARY_KEY).then(function (r) {
|
||||||
|
var lib = r[LIBRARY_KEY] || {};
|
||||||
|
return {
|
||||||
|
v: 1,
|
||||||
|
projects: Array.isArray(lib.projects) ? lib.projects : [],
|
||||||
|
items: Array.isArray(lib.items) ? lib.items : []
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLibrary(lib) {
|
||||||
|
var payload = {};
|
||||||
|
payload[LIBRARY_KEY] = lib;
|
||||||
|
return set(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Aynı video ikinci kez eklenirse eski kaydın yerine geçer (tek girdi,
|
||||||
|
* proje bilgisi korunur), listenin başına alınır. */
|
||||||
|
function libraryAdd(item) {
|
||||||
|
return readLibrary().then(function (lib) {
|
||||||
|
var old = null;
|
||||||
|
lib.items = lib.items.filter(function (it) {
|
||||||
|
if (it.videoId === item.videoId) { old = it; return false; }
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
if (old && item.projectId == null) item.projectId = old.projectId;
|
||||||
|
lib.items.unshift(item);
|
||||||
|
return writeLibrary(lib).then(function () { return lib; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function libraryRemove(videoId) {
|
||||||
|
return readLibrary().then(function (lib) {
|
||||||
|
lib.items = lib.items.filter(function (it) { return it.videoId !== videoId; });
|
||||||
|
return writeLibrary(lib);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function libraryHas(videoId) {
|
||||||
|
return readLibrary().then(function (lib) {
|
||||||
|
return lib.items.some(function (it) { return it.videoId === videoId; });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function clearBaselines() {
|
function clearBaselines() {
|
||||||
mem = {};
|
mem = {};
|
||||||
return get("blIndex").then(function (r) {
|
return get("blIndex").then(function (r) {
|
||||||
@@ -139,6 +191,11 @@ var OBStore = (function () {
|
|||||||
writeBaseline: writeBaseline,
|
writeBaseline: writeBaseline,
|
||||||
readChannelId: readChannelId,
|
readChannelId: readChannelId,
|
||||||
writeChannelId: writeChannelId,
|
writeChannelId: writeChannelId,
|
||||||
|
readLibrary: readLibrary,
|
||||||
|
writeLibrary: writeLibrary,
|
||||||
|
libraryAdd: libraryAdd,
|
||||||
|
libraryRemove: libraryRemove,
|
||||||
|
libraryHas: libraryHas,
|
||||||
clearBaselines: clearBaselines,
|
clearBaselines: clearBaselines,
|
||||||
stats: stats
|
stats: stats
|
||||||
};
|
};
|
||||||
|
|||||||
+48
-4
@@ -8,6 +8,10 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
right: 4px;
|
right: 4px;
|
||||||
bottom: 30px;
|
bottom: 30px;
|
||||||
|
/* Mütevazı kalmalı: yüksek bir değer rozeti YouTube'un kendi katmanlarının
|
||||||
|
(açıklama penceresi gibi dialoglar ~2200) üstüne taşırıyor. Hover
|
||||||
|
önizlemesinin üstünde kalma işi kartın içindeki bu rozetin değil,
|
||||||
|
body'ye basılan hayalet kopyanın. */
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
min-width: 34px;
|
min-width: 34px;
|
||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
@@ -18,14 +22,21 @@
|
|||||||
background: rgba(0, 0, 0, 0.78);
|
background: rgba(0, 0, 0, 0.78);
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
cursor: default;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shorts kapağının altı izlenme metniyle dolu, orada rozet üste gider. */
|
.ob-badge:hover {
|
||||||
.ob-badge.ob-short {
|
outline: 1px solid rgba(255, 255, 255, 0.55);
|
||||||
top: 6px;
|
}
|
||||||
|
|
||||||
|
/* Hayalet rozet: hover önizleme katmanının üstünde duran fixed kopya;
|
||||||
|
left/top JS'ten inline verilir. */
|
||||||
|
.ob-badge.ob-ghost {
|
||||||
|
position: fixed;
|
||||||
|
right: auto;
|
||||||
bottom: auto;
|
bottom: auto;
|
||||||
|
z-index: 2147483647;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ob-badge.ob-loading {
|
.ob-badge.ob-loading {
|
||||||
@@ -52,6 +63,39 @@
|
|||||||
.ob-t3 { background: rgba(180, 83, 9, 0.94); }
|
.ob-t3 { background: rgba(180, 83, 9, 0.94); }
|
||||||
.ob-t4 { background: rgba(159, 18, 57, 0.95); }
|
.ob-t4 { background: rgba(159, 18, 57, 0.95); }
|
||||||
|
|
||||||
|
/* --- Rozet menüsü --- */
|
||||||
|
|
||||||
|
/* Menü de rozet gibi tema bağımsız koyu: sayfanın her yerinde aynı görünür. */
|
||||||
|
.ob-menu {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 2147483000;
|
||||||
|
min-width: 190px;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #212121;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||||
|
font-family: "Roboto", "Segoe UI", sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-menu-item {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 11px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #eee;
|
||||||
|
font: 500 13px/1.3 inherit;
|
||||||
|
font-family: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ob-menu-item:hover:not(:disabled) { background: rgba(255, 255, 255, 0.12); }
|
||||||
|
.ob-menu-item:disabled { color: #9aa0a6; cursor: default; }
|
||||||
|
|
||||||
/* --- Izleme sayfasi paneli --- */
|
/* --- Izleme sayfasi paneli --- */
|
||||||
|
|
||||||
.ob-panel {
|
.ob-panel {
|
||||||
|
|||||||
+17
-3
@@ -98,6 +98,7 @@ eq(tiny.score, null, "3'ten az örnek: skor yok");
|
|||||||
|
|
||||||
console.log("\n--- gerçek InnerTube çağrısı ---");
|
console.log("\n--- gerçek InnerTube çağrısı ---");
|
||||||
const CHANNEL = process.argv[2] || "UCXuqSBlHAE6Xw-yeJA0Tunw"; /* Linus Tech Tips */
|
const CHANNEL = process.argv[2] || "UCXuqSBlHAE6Xw-yeJA0Tunw"; /* Linus Tech Tips */
|
||||||
|
let probe = null; /* videoDetails'ı sınamak için kanalın güncel bir videosu */
|
||||||
OBTube.channelTab(CHANNEL, "videos", 30).then((items) => {
|
OBTube.channelTab(CHANNEL, "videos", 30).then((items) => {
|
||||||
console.log("videos sekmesi: " + items.length + " video");
|
console.log("videos sekmesi: " + items.length + " video");
|
||||||
const withViews = items.filter((v) => v.views);
|
const withViews = items.filter((v) => v.views);
|
||||||
@@ -108,6 +109,7 @@ OBTube.channelTab(CHANNEL, "videos", 30).then((items) => {
|
|||||||
console.log(" " + v.videoId + " | " + OBParse.humanCount(v.views) + " | " +
|
console.log(" " + v.videoId + " | " + OBParse.humanCount(v.views) + " | " +
|
||||||
(v.ageDays == null ? "?" : v.ageDays.toFixed(1) + " gün") + " | " + v.title.slice(0, 50));
|
(v.ageDays == null ? "?" : v.ageDays.toFixed(1) + " gün") + " | " + v.title.slice(0, 50));
|
||||||
}
|
}
|
||||||
|
probe = withViews.length ? withViews[0] : null;
|
||||||
const views = withViews.map((v) => v.views);
|
const views = withViews.map((v) => v.views);
|
||||||
console.log("medyan: " + OBParse.humanCount(OBParse.median(views)));
|
console.log("medyan: " + OBParse.humanCount(OBParse.median(views)));
|
||||||
if (withViews.length < 10) { fails++; console.log("FAIL izlenmesi çözülen video sayısı çok düşük"); }
|
if (withViews.length < 10) { fails++; console.log("FAIL izlenmesi çözülen video sayısı çok düşük"); }
|
||||||
@@ -118,10 +120,22 @@ OBTube.channelTab(CHANNEL, "videos", 30).then((items) => {
|
|||||||
return OBTube.resolveChannel("@LinusTechTips");
|
return OBTube.resolveChannel("@LinusTechTips");
|
||||||
}).then((id) => {
|
}).then((id) => {
|
||||||
eq(id, CHANNEL, "handle -> kanal kimliği");
|
eq(id, CHANNEL, "handle -> kanal kimliği");
|
||||||
return OBTube.videoDetails("dQw4w9WgXcQ");
|
return OBTube.resolveChannel("@MesutCevik");
|
||||||
|
}).then((id) => {
|
||||||
|
/* Eski özel adresi olan bir kanal: YouTube @handle'ı önce
|
||||||
|
* youtube.com/mesutcevik adresine yolluyor, kimlik ancak ikinci adımda
|
||||||
|
* geliyor. Tek adımda çözen kod bu kanallarda "kanal çözülemedi" veriyor ve
|
||||||
|
* o kanalın hiçbir kartı puanlanmıyordu. */
|
||||||
|
eq(id, "UCOFafpmI_dt8SxisbKniN4A", "yönlendirmeli handle -> kanal kimliği");
|
||||||
|
/* Bilerek kanalın güncel bir videosu: sabit bir klasik ("dQw4w9WgXcQ")
|
||||||
|
* YouTube tarafında ayrıcalıklı davranıp uç bozulduğunda bile yanıt
|
||||||
|
* verebiliyor ve testi yanlış yere yeşil gösteriyordu. */
|
||||||
|
if (!probe) { fails++; console.log("FAIL sınanacak video bulunamadı"); return {}; }
|
||||||
|
return OBTube.videoDetails(probe.videoId);
|
||||||
}).then((d) => {
|
}).then((d) => {
|
||||||
console.log("player ucu: kanal " + d.channelId + ", izlenme " + OBParse.humanCount(d.views));
|
console.log("video ucu: kanal " + d.channelId + ", izlenme " + OBParse.humanCount(d.views));
|
||||||
if (!d.channelId) { fails++; console.log("FAIL player ucu kanal kimliği vermedi"); }
|
eq(d.channelId, CHANNEL, "video ucu kanal kimliği");
|
||||||
|
if (!d.views) { fails++; console.log("FAIL video ucu izlenme vermedi"); }
|
||||||
console.log(fails ? "\n" + fails + " test başarısız" : "\nhepsi geçti");
|
console.log(fails ? "\n" + fails + " test başarısız" : "\nhepsi geçti");
|
||||||
process.exit(fails ? 1 : 0);
|
process.exit(fails ? 1 : 0);
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user