FUND 1 — 'Key korrekt, aber als ungueltig angezeigt': Stimmt beides. Live gemessen: Der aktuelle Forum-Key ist in Ordnung, aber MakeMKV 1.18.4 ist ZU ALT fuer ihn — makemkvcon meldet 5020 UND 5021 zusammen, und die alte Pruefung brach beim ersten Code ab und beschuldigte den Schluessel. urteilAusAusgabe sammelt jetzt ALLE Codes (5021 schlaegt 5020, MakeMKV-Version aus MSG 1005 wird mitgelesen) und die Kachel sagt den wahren Satz: 'Der Key ist in Ordnung — MakeMKV 1.18.4 ist zu alt. Rippy hat ihn entfernt, damit der Beta-Modus weiterlaeuft (Rips klappen trotzdem). Abhilfe: MakeMKV aktualisieren.' Neues Feld weiterBetrieb: Beta-Modus ist GELB (Warnung), nicht rot. Test mit der echten Ausgabe als Fixture. FUND 2 — 'Komprimieren auf CPU statt GPU': Der Default 'H.265 MKV 1080p30' ist ein CPU-Preset. Neue GEMESSENE Empfehlung (gemeinsam/preset-empfehlung.ts): aus den echten Backends und der echten Preset-Liste des eigenen HandBrake — VCN vor NVENC vor QSV vor CPU, nur Namen die WIRKLICH in der Liste stehen, HandBrake-Presets deckeln die Aufloesung nur (DVD bleibt SD). Kette: eigene Wahl > allgemeines Preset > Empfehlung > CPU-Default. Auf dem Commander-PC: bluray/dvd -> 'H.265 VCN 1080p', uhd -> 'H.265 VCN 2160p 4K'. Die Preset-Auswahl zeigt 'automatisch: <Empfehlung>' als Leer-Option. FUND 3 — 'keine Alternative waehlbar': 'Aendern …'-Dialog an der Laufwerks-Kachel (§ 5 Schritt 4): TMDb-Suche (Film + Serie, mit Postern), Klick uebernimmt — Nutzer-Wahl gilt als 100 % und steuert Ablage-Ordner, NFO und Poster. Neue Nachrichten metadaten-suchen / zuordnung-setzen / metadaten-vorschlaege. Dazu: Poster in der Laufwerks-Kachel (CSP um image.tmdb.org erweitert), Restzeit-Schaetzung am Fortschritt. 192 Tests gruen, Smoke gruen. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
0e1c927e7b
commit
c5dd90cc18
@@ -53,6 +53,9 @@ export interface PipelineUmgebung {
|
||||
/** Wurzel der Ablage (Roh-Ordner und fertige Filme liegen darunter). */
|
||||
ablage: () => string
|
||||
einstellungen: () => Einstellungen
|
||||
/** Gemessene Preset-Empfehlung (Hardware zuerst) für diesen Disc-Typ —
|
||||
* greift nur, wenn der Nutzer nichts eingestellt hat. */
|
||||
presetEmpfehlung?: (discTyp: string) => string
|
||||
melden: (status: RipStatus) => void
|
||||
/** Die letzte Metadaten-Zuordnung dieses Laufwerks (W-4) — oder null. */
|
||||
zuordnung?: (id: string) => Zuordnung | null
|
||||
@@ -204,7 +207,7 @@ export class Pipeline {
|
||||
return
|
||||
}
|
||||
|
||||
const preset = presetFuer(geraet.typ, einstellungen)
|
||||
const preset = presetFuer(geraet.typ, einstellungen, this.umgebung.presetEmpfehlung?.(geraet.typ) ?? '')
|
||||
// Mit Metadaten (W-4) heißt der Ordner „<Titel> (Jahr)" — daran
|
||||
// erkennen Jellyfin & Co. den Film; ohne bleibt der Vorgangsname.
|
||||
const zuordnung = this.umgebung.zuordnung?.(id) ?? null
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
istHauptNachricht,
|
||||
type DbStatus,
|
||||
type KernNachricht,
|
||||
type MetaVorschlag,
|
||||
} from '../gemeinsam/nachrichten'
|
||||
import { Pipeline } from './ablauf/pipeline'
|
||||
import type { LaufwerkApi } from './laufwerk/codes'
|
||||
@@ -29,6 +30,7 @@ import { discFingerprint, discTitel } from './metadaten/discmerkmale'
|
||||
import { OmdbClient } from './metadaten/omdb'
|
||||
import { TmdbClient } from './metadaten/tmdb'
|
||||
import { zuordnen, type Zuordnung } from './metadaten/zuordnung'
|
||||
import { presetEmpfehlung } from '../gemeinsam/preset-empfehlung'
|
||||
import { Datenbank } from './speicher/db'
|
||||
import { finden } from './werkzeuge/katalog'
|
||||
|
||||
@@ -124,6 +126,85 @@ async function discErmitteln(id: string, geraetepfad: string, groesseBytes: numb
|
||||
}
|
||||
}
|
||||
|
||||
// § 5 Schritt 4: „nein → fragen" — der Nutzer sucht und WÄHLT selbst.
|
||||
function jahrAus(datum: string | undefined): number | null {
|
||||
if (datum === undefined || datum.length < 4) return null
|
||||
const jahr = Number(datum.slice(0, 4))
|
||||
return Number.isFinite(jahr) && jahr > 0 ? jahr : null
|
||||
}
|
||||
|
||||
async function metadatenSuchen(id: string, text: string): Promise<void> {
|
||||
try {
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
if (!tmdb.verfuegbar) {
|
||||
anFenster({ art: 'kern-fehler', text: 'Metadaten-Suche braucht einen TMDb-Key (Einstellungen → Metadaten).' })
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
return
|
||||
}
|
||||
const [filme, serien] = await Promise.all([tmdb.searchMovie(text), tmdb.searchTv(text)])
|
||||
const vorschlaege: MetaVorschlag[] = [
|
||||
...(filme ?? []).slice(0, 6).map((f) => ({
|
||||
typ: 'movie' as const,
|
||||
tmdbId: f.id,
|
||||
titel: f.title ?? '',
|
||||
jahr: jahrAus(f.release_date),
|
||||
posterPfad: f.poster_path ?? '',
|
||||
})),
|
||||
...(serien ?? []).slice(0, 6).map((s) => ({
|
||||
typ: 'tv' as const,
|
||||
tmdbId: s.id,
|
||||
titel: s.name ?? '',
|
||||
jahr: jahrAus(s.first_air_date),
|
||||
posterPfad: s.poster_path ?? '',
|
||||
})),
|
||||
].filter((v) => v.titel.length > 0)
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege })
|
||||
} catch (fehler) {
|
||||
anFenster({ art: 'kern-fehler', text: `Metadaten-Suche: ${String(fehler)}` })
|
||||
anFenster({ art: 'metadaten-vorschlaege', id, vorschlaege: [] })
|
||||
}
|
||||
}
|
||||
|
||||
async function zuordnungSetzen(id: string, vorschlag: MetaVorschlag): Promise<void> {
|
||||
try {
|
||||
const tmdb = new TmdbClient(db?.einstellungLesen('tmdbApiKey') ?? '')
|
||||
const details =
|
||||
vorschlag.typ === 'movie' ? await tmdb.movieDetails(vorschlag.tmdbId) : await tmdb.tvDetails(vorschlag.tmdbId)
|
||||
const zuordnung: Zuordnung = {
|
||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||
// Vom Nutzer gewählt = sicher. Genau dafür ist die Frage da (§ 5).
|
||||
confidence: 1,
|
||||
meta: {
|
||||
typ: vorschlag.typ,
|
||||
id: vorschlag.tmdbId,
|
||||
titel: (vorschlag.typ === 'movie' ? details?.title : details?.name) ?? vorschlag.titel,
|
||||
jahr: jahrAus(vorschlag.typ === 'movie' ? details?.release_date : details?.first_air_date) ?? vorschlag.jahr,
|
||||
beschreibung: details?.overview ?? '',
|
||||
posterPfad: details?.poster_path ?? vorschlag.posterPfad,
|
||||
laufzeitMinuten: details?.runtime ?? 0,
|
||||
genres: (details?.genres ?? []).map((g) => g.name),
|
||||
quelle: 'tmdb',
|
||||
},
|
||||
}
|
||||
discZuordnungen.set(id, zuordnung)
|
||||
anHauptUndFenster({
|
||||
art: 'disc-info',
|
||||
info: {
|
||||
id,
|
||||
titel: zuordnung.titel,
|
||||
jahr: zuordnung.jahr,
|
||||
confidence: 1,
|
||||
quelle: 'deine Wahl',
|
||||
posterPfad: zuordnung.meta.posterPfad,
|
||||
schonGerippt: db?.bibliothekSchonGerippt(discFingerprints.get(id) ?? '') ?? null,
|
||||
},
|
||||
})
|
||||
} catch (fehler) {
|
||||
anFenster({ art: 'kern-fehler', text: `Zuordnung übernehmen: ${String(fehler)}` })
|
||||
}
|
||||
}
|
||||
|
||||
const laufwerkeBereit: Promise<void> = (async () => {
|
||||
try {
|
||||
if (process.platform !== 'win32') {
|
||||
@@ -229,6 +310,7 @@ const ripVerwaltung = new Pipeline({
|
||||
},
|
||||
ablage: ablageOrdner,
|
||||
einstellungen: einstellungenLaden,
|
||||
presetEmpfehlung: (typ) => presetEmpfehlung(typ, werkzeugAuskunft.backends, werkzeugAuskunft.presets),
|
||||
melden: (status) => anHauptUndFenster({ art: 'rip-status', status }),
|
||||
zuordnung: (id) => discZuordnungen.get(id) ?? null,
|
||||
fingerprint: (id) => discFingerprints.get(id) ?? null,
|
||||
@@ -303,12 +385,17 @@ async function schluesselLauf(): Promise<void> {
|
||||
// Täglich neu prüfen (§ 9.1) — ohne den Prozess-Exit aufzuhalten.
|
||||
setInterval(() => void schluesselLauf(), 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
// Die gemessene Auskunft bleibt liegen — die Pipeline braucht sie für die
|
||||
// Preset-Empfehlung, auch wenn gerade kein Fenster offen ist.
|
||||
let werkzeugAuskunft: { presets: string[]; backends: string[] } = { presets: [], backends: [] }
|
||||
|
||||
async function werkzeugeSenden(): Promise<void> {
|
||||
const makemkvcon = finden('makemkv', db?.einstellungLesen('werkzeug.makemkv') ?? '')
|
||||
const handbrake = finden('handbrake', db?.einstellungLesen('werkzeug.handbrake') ?? '')
|
||||
const flac = finden('flac', db?.einstellungLesen('werkzeug.flac') ?? '')
|
||||
const { encoderMessen } = await import('./komprimieren/encoder')
|
||||
const gemessen = await encoderMessen(handbrake)
|
||||
werkzeugAuskunft = { presets: gemessen.presets, backends: gemessen.backends }
|
||||
anFenster({
|
||||
art: 'werkzeuge',
|
||||
auskunft: { makemkvcon, handbrake, flac, presets: gemessen.presets, backends: gemessen.backends },
|
||||
@@ -362,6 +449,10 @@ process.parentPort.on('message', (ereignis) => {
|
||||
void laufwerkeBereit.then(() => ripVerwaltung.starten(frage.id))
|
||||
} else if (frage.art === 'rip-abbruch') {
|
||||
ripVerwaltung.abbrechen(frage.id)
|
||||
} else if (frage.art === 'metadaten-suchen') {
|
||||
void metadatenSuchen(frage.id, frage.text)
|
||||
} else if (frage.art === 'zuordnung-setzen') {
|
||||
void zuordnungSetzen(frage.id, frage.vorschlag)
|
||||
} else if (frage.art === 'einstellung-setzen') {
|
||||
if (!ERLAUBTE_EINSTELLUNGEN.has(frage.schluessel)) {
|
||||
anFenster({ art: 'kern-fehler', text: `Unbekannter Einstellungs-Schlüssel: ${frage.schluessel}` })
|
||||
@@ -410,4 +501,7 @@ einstellungenSenden()
|
||||
// der Gerüst-Beweis soll ohne Netz und ohne MakeMKV grün sein.
|
||||
if (!process.argv.includes('--smoke-kern') && process.env['RIPPY_OHNE_SCHLUESSEL'] !== '1') {
|
||||
void schluesselLauf()
|
||||
// Werkzeuge einmal beim Start messen — die Preset-Empfehlung der
|
||||
// Pipeline braucht Backends und Preset-Liste auch ohne offenes Fenster.
|
||||
void werkzeugeSenden()
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
// mit den Befunden vom 25.07.2026 begründet: EIN Preset für alles rechnete
|
||||
// 4K auf 1080p herunter und DVDs hinauf.
|
||||
|
||||
export const DEFAULT_HB_PRESET = 'H.265 MKV 1080p30'
|
||||
import { DEFAULT_HB_PRESET, presetEmpfehlung } from '../../gemeinsam/preset-empfehlung'
|
||||
|
||||
export { DEFAULT_HB_PRESET, presetEmpfehlung }
|
||||
|
||||
/** Einstellungs-Schlüssel je Disc-Typ. */
|
||||
export const PRESET_SCHLUESSEL: Record<string, string> = {
|
||||
@@ -32,17 +34,19 @@ export function komprimierenFuer(discTyp: string, einstellungen: Einstellungen):
|
||||
}
|
||||
|
||||
/** Welches HandBrake-Preset gilt für diesen Disc-Typ? (pur)
|
||||
* Kette: Typ-Preset → allgemeines transcodePreset → DEFAULT. PRESET_KEINE
|
||||
* wird ÜBERSPRUNGEN — diese Funktion darf nie „keine" als Preset-Namen
|
||||
* liefern, sonst bekäme HandBrake `--preset keine` und scheiterte. */
|
||||
export function presetFuer(discTyp: string, einstellungen: Einstellungen): string {
|
||||
* Kette: Typ-Preset → allgemeines transcodePreset → EMPFEHLUNG (gemessen,
|
||||
* Hardware zuerst) → CPU-DEFAULT. PRESET_KEINE wird ÜBERSPRUNGEN — diese
|
||||
* Funktion darf nie „keine" als Preset-Namen liefern, sonst bekäme
|
||||
* HandBrake `--preset keine` und scheiterte. */
|
||||
export function presetFuer(discTyp: string, einstellungen: Einstellungen, empfehlung = ''): string {
|
||||
const schluessel = PRESET_SCHLUESSEL[discTyp] ?? ''
|
||||
if (schluessel.length > 0) {
|
||||
const eigen = String(einstellungen[schluessel] ?? '').trim()
|
||||
if (eigen.length > 0 && eigen !== PRESET_KEINE) return eigen
|
||||
}
|
||||
const allgemein = String(einstellungen['transcodePreset'] ?? '').trim()
|
||||
return allgemein.length > 0 ? allgemein : DEFAULT_HB_PRESET
|
||||
if (allgemein.length > 0) return allgemein
|
||||
return empfehlung.length > 0 ? empfehlung : DEFAULT_HB_PRESET
|
||||
}
|
||||
|
||||
/** „deu, eng , ,DEU" → ["deu","eng"] — HandBrake will ISO-639-2 klein;
|
||||
|
||||
@@ -7,6 +7,9 @@ export interface FilmTreffer {
|
||||
id: number
|
||||
title?: string
|
||||
name?: string
|
||||
poster_path?: string
|
||||
release_date?: string
|
||||
first_air_date?: string
|
||||
}
|
||||
|
||||
export interface FilmDetails {
|
||||
|
||||
@@ -91,15 +91,24 @@ export interface KeyUrteil {
|
||||
ok: boolean
|
||||
code: number | null
|
||||
grund: string
|
||||
/** Die MakeMKV-Version aus MSG 1005 („MakeMKV v1.18.4 …") — '' wenn
|
||||
* nicht erkennbar. */
|
||||
version: string
|
||||
}
|
||||
|
||||
/** Nimmt MakeMKV den hinterlegten Schlüssel an? — es selbst gefragt:
|
||||
* `info disc:9999` braucht keinen Datenträger, die Freischaltungs-Meldung
|
||||
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026). */
|
||||
* kommt trotzdem (makemkv_aufruf.py, 29.08.2026).
|
||||
*
|
||||
* ⚠ ALLE Codes einsammeln, dann urteilen: Beim Commander kamen 5020 UND
|
||||
* 5021 zusammen (30.08.2026 live gemessen) — 5020 allein klingt nach
|
||||
* „Schlüssel kaputt", die URSACHE steht aber in 5021: MakeMKV ist zu alt
|
||||
* für den aktuellen Beta-Key. Wer beim ersten Code abbricht, beschuldigt
|
||||
* den falschen. */
|
||||
export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUrteil> {
|
||||
return new Promise((fertig) => {
|
||||
if (programm.length === 0) {
|
||||
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden' })
|
||||
fertig({ ok: false, code: null, grund: 'MakeMKV nicht gefunden', version: '' })
|
||||
return
|
||||
}
|
||||
const kind = spawn(programm, ['-r', '--noscan', 'info', 'disc:9999'], {
|
||||
@@ -112,28 +121,43 @@ export function keyPruefen(programm: string, timeoutMs = 120_000): Promise<KeyUr
|
||||
const zeiger = setTimeout(() => kind.kill(), timeoutMs)
|
||||
kind.on('error', (fehler) => {
|
||||
clearTimeout(zeiger)
|
||||
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})` })
|
||||
fertig({ ok: false, code: null, grund: `MakeMKV nicht befragbar (${String(fehler)})`, version: '' })
|
||||
})
|
||||
kind.on('close', () => {
|
||||
clearTimeout(zeiger)
|
||||
for (const zeile of textVon(Buffer.concat(stuecke)).split(/\r?\n/)) {
|
||||
if (!zeile.startsWith('MSG:')) continue
|
||||
const code = Number(zeile.slice(4).split(',', 1)[0])
|
||||
if (!Number.isInteger(code)) continue
|
||||
if (KEY_ABGELEHNT.includes(code) || code === 5051) {
|
||||
fertig({ ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}` })
|
||||
return
|
||||
}
|
||||
}
|
||||
fertig({ ok: true, code: null, grund: '' })
|
||||
fertig(urteilAusAusgabe(textVon(Buffer.concat(stuecke))))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Die Ausgabe eines info-Laufs → Urteil (pur, testbar). */
|
||||
export function urteilAusAusgabe(ausgabe: string): KeyUrteil {
|
||||
const codes: number[] = []
|
||||
let version = ''
|
||||
for (const zeile of ausgabe.split(/\r?\n/)) {
|
||||
if (!zeile.startsWith('MSG:')) continue
|
||||
const code = Number(zeile.slice(4).split(',', 1)[0])
|
||||
if (!Number.isInteger(code)) continue
|
||||
if (code === 1005) {
|
||||
const treffer = /MakeMKV v([0-9][0-9.]*)/.exec(zeile)
|
||||
if (treffer !== null) version = treffer[1]
|
||||
}
|
||||
if (KEY_ABGELEHNT.includes(code) || code === 5051) codes.push(code)
|
||||
}
|
||||
if (codes.length === 0) return { ok: true, code: null, grund: '', version }
|
||||
// 5021 schlägt alles: Es benennt die URSACHE (Version zu alt) — 5020
|
||||
// ist dann nur die Folge und würde den Schlüssel zu Unrecht beschuldigen.
|
||||
const code = codes.includes(5021) ? 5021 : codes[0]
|
||||
return { ok: false, code, grund: KRITISCHE_CODES[code] ?? `MSG ${code}`, version }
|
||||
}
|
||||
|
||||
export interface SchluesselStand {
|
||||
/** 'dauerlizenz' | 'forum' | 'keiner' */
|
||||
quelle: string
|
||||
angenommen: boolean | null
|
||||
/** Laufen Rips trotzdem? (Beta-Modus ohne Key = ja — das ist eine
|
||||
* WARNUNG mit Handlungsempfehlung, kein Ausfall.) */
|
||||
weiterBetrieb: boolean
|
||||
gueltigBis: string
|
||||
geholtAm: string
|
||||
text: string
|
||||
@@ -155,9 +179,12 @@ export async function schluesselKette(
|
||||
return {
|
||||
quelle: 'dauerlizenz',
|
||||
angenommen: urteil.ok,
|
||||
weiterBetrieb: urteil.ok,
|
||||
gueltigBis: '',
|
||||
geholtAm: jetztIso,
|
||||
text: urteil.ok ? 'Eigener Schlüssel hinterlegt und angenommen.' : `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
||||
text: urteil.ok
|
||||
? `Eigener Schlüssel hinterlegt und von MakeMKV${urteil.version.length > 0 ? ` ${urteil.version}` : ''} angenommen.`
|
||||
: `Eigener Schlüssel abgelehnt: ${urteil.grund}`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +196,7 @@ export async function schluesselKette(
|
||||
return {
|
||||
quelle: 'keiner',
|
||||
angenommen: urteil.ok,
|
||||
weiterBetrieb: true,
|
||||
gueltigBis: '',
|
||||
geholtAm: jetztIso,
|
||||
text: urteil.ok
|
||||
@@ -181,15 +209,25 @@ export async function schluesselKette(
|
||||
const urteil = await keyPruefen(makemkvcon)
|
||||
if (!urteil.ok) {
|
||||
// Ein abgelehnter Schlüssel ist schlimmer als keiner — zurücknehmen.
|
||||
// (Live gemessen 30.08.2026: MIT abgelehntem Key verweigert MakeMKV
|
||||
// alles; OHNE läuft es im Beta-Modus weiter und rippt.)
|
||||
keyZuruecknehmen()
|
||||
}
|
||||
const versionsText = urteil.version.length > 0 ? ` ${urteil.version}` : ''
|
||||
return {
|
||||
quelle: 'forum',
|
||||
angenommen: urteil.ok,
|
||||
// Der Zurücknahme-Weg lässt MakeMKV im Beta-Modus weiterlaufen —
|
||||
// Rips gehen, nur eben ohne Freischaltungs-Komfort.
|
||||
weiterBetrieb: true,
|
||||
gueltigBis: forum.gueltigBis,
|
||||
geholtAm: jetztIso,
|
||||
text: urteil.ok
|
||||
? `Beta-Key aus dem Forum hinterlegt${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
||||
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt. Vermutlich ist MakeMKV zu alt.`,
|
||||
? `Beta-Key aus dem Forum hinterlegt und von MakeMKV${versionsText} angenommen${forum.gueltigBis.length > 0 ? ` — gültig bis ${forum.gueltigBis}` : ''}.`
|
||||
: urteil.code === 5021
|
||||
? `Der aktuelle Beta-Key ist in Ordnung — aber MakeMKV${versionsText} ist ZU ALT für ihn. ` +
|
||||
'Rippy hat ihn wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft (so klappen Rips trotzdem). ' +
|
||||
'Abhilfe: MakeMKV von makemkv.com aktualisieren — danach hinterlegt Rippy den Key automatisch neu.'
|
||||
: `Beta-Key abgelehnt (${urteil.grund}) — wieder entfernt, damit MakeMKV im Beta-Modus weiterläuft.`,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user