31 changed files with 1943 additions and 677 deletions
@ -0,0 +1,152 @@
@@ -0,0 +1,152 @@
|
||||
import { describe, it } from "vitest"; |
||||
|
||||
import { |
||||
getMWCaptionTypeFromUrl, |
||||
isSupportedSubtitle, |
||||
parseSubtitles, |
||||
} from "@/backend/helpers/captions"; |
||||
import { MWCaptionType } from "@/backend/helpers/streams"; |
||||
|
||||
import { |
||||
ass, |
||||
multilineSubtitlesTestVtt, |
||||
srt, |
||||
visibleSubtitlesTestVtt, |
||||
vtt, |
||||
} from "./testdata"; |
||||
|
||||
describe("subtitles", () => { |
||||
it("should return true if given url ends with a known subtitle type", ({ |
||||
expect, |
||||
}) => { |
||||
expect(isSupportedSubtitle("https://example.com/test.srt")).toBe(true); |
||||
expect(isSupportedSubtitle("https://example.com/test.vtt")).toBe(true); |
||||
expect(isSupportedSubtitle("https://example.com/test.txt")).toBe(false); |
||||
}); |
||||
|
||||
it("should return corresponding MWCaptionType", ({ expect }) => { |
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.srt")).toBe( |
||||
MWCaptionType.SRT |
||||
); |
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.vtt")).toBe( |
||||
MWCaptionType.VTT |
||||
); |
||||
expect(getMWCaptionTypeFromUrl("https://example.com/test.txt")).toBe( |
||||
MWCaptionType.UNKNOWN |
||||
); |
||||
}); |
||||
|
||||
it("should throw when empty text is given", ({ expect }) => { |
||||
expect(() => parseSubtitles("")).toThrow("Given text is empty"); |
||||
}); |
||||
|
||||
it("should parse srt", ({ expect }) => { |
||||
const parsed = parseSubtitles(srt); |
||||
const parsedSrt = [ |
||||
{ |
||||
type: "caption", |
||||
index: 1, |
||||
start: 0, |
||||
end: 0, |
||||
duration: 0, |
||||
content: "Test", |
||||
text: "Test", |
||||
}, |
||||
{ |
||||
type: "caption", |
||||
index: 2, |
||||
start: 0, |
||||
end: 0, |
||||
duration: 0, |
||||
content: "Test", |
||||
text: "Test", |
||||
}, |
||||
]; |
||||
expect(parsed).toHaveLength(2); |
||||
expect(parsed).toEqual(parsedSrt); |
||||
}); |
||||
|
||||
it("should parse vtt", ({ expect }) => { |
||||
const parsed = parseSubtitles(vtt); |
||||
const parsedVtt = [ |
||||
{ |
||||
type: "caption", |
||||
index: 1, |
||||
start: 0, |
||||
end: 4000, |
||||
duration: 4000, |
||||
content: "Where did he go?", |
||||
text: "Where did he go?", |
||||
}, |
||||
{ |
||||
type: "caption", |
||||
index: 2, |
||||
start: 3000, |
||||
end: 6500, |
||||
duration: 3500, |
||||
content: "I think he went down this lane.", |
||||
text: "I think he went down this lane.", |
||||
}, |
||||
{ |
||||
type: "caption", |
||||
index: 3, |
||||
start: 4000, |
||||
end: 6500, |
||||
duration: 2500, |
||||
content: "What are you waiting for?", |
||||
text: "What are you waiting for?", |
||||
}, |
||||
]; |
||||
expect(parsed).toHaveLength(3); |
||||
expect(parsed).toEqual(parsedVtt); |
||||
}); |
||||
|
||||
it("should parse ass", ({ expect }) => { |
||||
const parsed = parseSubtitles(ass); |
||||
expect(parsed).toHaveLength(3); |
||||
}); |
||||
|
||||
it("should delay subtitles when given a delay", ({ expect }) => { |
||||
const videoTime = 11; |
||||
let delayedSeconds = 0; |
||||
const parsed = parseSubtitles(visibleSubtitlesTestVtt); |
||||
const isVisible = (start: number, end: number, delay: number): boolean => { |
||||
const delayedStart = start / 1000 + delay; |
||||
const delayedEnd = end / 1000 + delay; |
||||
return ( |
||||
Math.max(0, delayedStart) <= videoTime && |
||||
Math.max(0, delayedEnd) >= videoTime |
||||
); |
||||
}; |
||||
const visibleSubtitles = parsed.filter((c) => |
||||
isVisible(c.start, c.end, delayedSeconds) |
||||
); |
||||
expect(visibleSubtitles).toHaveLength(1); |
||||
|
||||
delayedSeconds = 10; |
||||
const delayedVisibleSubtitles = parsed.filter((c) => |
||||
isVisible(c.start, c.end, delayedSeconds) |
||||
); |
||||
expect(delayedVisibleSubtitles).toHaveLength(1); |
||||
|
||||
delayedSeconds = -10; |
||||
const delayedVisibleSubtitles2 = parsed.filter((c) => |
||||
isVisible(c.start, c.end, delayedSeconds) |
||||
); |
||||
expect(delayedVisibleSubtitles2).toHaveLength(1); |
||||
|
||||
delayedSeconds = -20; |
||||
const delayedVisibleSubtitles3 = parsed.filter((c) => |
||||
isVisible(c.start, c.end, delayedSeconds) |
||||
); |
||||
expect(delayedVisibleSubtitles3).toHaveLength(1); |
||||
}); |
||||
|
||||
it("should parse multiline captions", ({ expect }) => { |
||||
const parsed = parseSubtitles(multilineSubtitlesTestVtt); |
||||
|
||||
expect(parsed[0].text).toBe(`- Test 1\n- Test 2\n- Test 3`); |
||||
expect(parsed[1].text).toBe(`- Test 4`); |
||||
expect(parsed[2].text).toBe(`- Test 6`); |
||||
}); |
||||
}); |
@ -0,0 +1,68 @@
@@ -0,0 +1,68 @@
|
||||
const srt = ` |
||||
1 |
||||
00:00:00,000 --> 00:00:00,000 |
||||
Test |
||||
|
||||
2 |
||||
00:00:00,000 --> 00:00:00,000 |
||||
Test |
||||
`;
|
||||
const vtt = ` |
||||
WEBVTT |
||||
|
||||
00:00:00.000 --> 00:00:04.000 position:10%,line-left align:left size:35% |
||||
Where did he go? |
||||
|
||||
00:00:03.000 --> 00:00:06.500 position:90% align:right size:35% |
||||
I think he went down this lane. |
||||
|
||||
00:00:04.000 --> 00:00:06.500 position:45%,line-right align:center size:35% |
||||
What are you waiting for? |
||||
`;
|
||||
const ass = `[Script Info]
|
||||
; Generated by Ebby.co |
||||
Title: |
||||
Original Script: |
||||
ScriptType: v4.00+ |
||||
Collisions: Normal |
||||
PlayResX: 384 |
||||
PlayResY: 288 |
||||
PlayDepth: 0 |
||||
Timer: 100.0 |
||||
WrapStyle: 0 |
||||
|
||||
[v4+ Styles] |
||||
Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding |
||||
Style: Default, Arial, 16, &H00FFFFFF, &H00000000, &H00000000, &H00000000, 0, 0, 0, 0, 100, 100, 0, 0, 1, 1, 0, 2, 15, 15, 15, 0 |
||||
|
||||
[Events] |
||||
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text |
||||
Dialogue: 0,0:00:10.00,0:00:20.00,Default,,0000,0000,0000,,This is the first subtitle. |
||||
Dialogue: 0,0:00:30.00,0:00:34.00,Default,,0000,0000,0000,,This is the second. |
||||
Dialogue: 0,0:00:34.00,0:00:35.00,Default,,0000,0000,0000,,Third`;
|
||||
|
||||
const visibleSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000 position:10%,line-left align:left size:35% |
||||
Test 1 |
||||
|
||||
00:00:10.000 --> 00:00:20.000 position:90% align:right size:35% |
||||
Test 2 |
||||
|
||||
00:00:20.000 --> 00:00:31.000 position:45%,line-right align:center size:35% |
||||
Test 3 |
||||
`;
|
||||
|
||||
const multilineSubtitlesTestVtt = `WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:10.000 |
||||
- Test 1\n- Test 2\n- Test 3 |
||||
|
||||
00:00:10.000 --> 00:00:20.000 |
||||
- Test 4 |
||||
|
||||
00:00:20.000 --> 00:00:31.000 |
||||
- Test 6 |
||||
`;
|
||||
|
||||
export { vtt, srt, ass, visibleSubtitlesTestVtt, multilineSubtitlesTestVtt }; |
@ -0,0 +1,251 @@
@@ -0,0 +1,251 @@
|
||||
import Base64 from "crypto-js/enc-base64"; |
||||
import Utf8 from "crypto-js/enc-utf8"; |
||||
|
||||
import { proxiedFetch, rawProxiedFetch } from "../helpers/fetch"; |
||||
import { registerProvider } from "../helpers/register"; |
||||
import { |
||||
MWCaptionType, |
||||
MWStreamQuality, |
||||
MWStreamType, |
||||
} from "../helpers/streams"; |
||||
import { MWMediaType } from "../metadata/types"; |
||||
|
||||
const twoEmbedBase = "https://www.2embed.to"; |
||||
|
||||
async function fetchCaptchaToken(recaptchaKey: string) { |
||||
const domainHash = Base64.stringify(Utf8.parse(twoEmbedBase)).replace( |
||||
/=/g, |
||||
"." |
||||
); |
||||
|
||||
const recaptchaRender = await proxiedFetch<any>( |
||||
`https://www.google.com/recaptcha/api.js?render=${recaptchaKey}` |
||||
); |
||||
|
||||
const vToken = recaptchaRender.substring( |
||||
recaptchaRender.indexOf("/releases/") + 10, |
||||
recaptchaRender.indexOf("/recaptcha__en.js") |
||||
); |
||||
|
||||
const recaptchaAnchor = await proxiedFetch<any>( |
||||
`https://www.google.com/recaptcha/api2/anchor?ar=1&hl=en&size=invisible&cb=flicklax&k=${recaptchaKey}&co=${domainHash}&v=${vToken}` |
||||
); |
||||
|
||||
const cToken = new DOMParser() |
||||
.parseFromString(recaptchaAnchor, "text/html") |
||||
.getElementById("recaptcha-token") |
||||
?.getAttribute("value"); |
||||
|
||||
if (!cToken) throw new Error("Unable to find cToken"); |
||||
|
||||
const payload = { |
||||
v: vToken, |
||||
reason: "q", |
||||
k: recaptchaKey, |
||||
c: cToken, |
||||
sa: "", |
||||
co: twoEmbedBase, |
||||
}; |
||||
|
||||
const tokenData = await proxiedFetch<string>( |
||||
`https://www.google.com/recaptcha/api2/reload?${new URLSearchParams( |
||||
payload |
||||
).toString()}`,
|
||||
{ |
||||
headers: { referer: "https://www.google.com/recaptcha/api2/" }, |
||||
method: "POST", |
||||
} |
||||
); |
||||
|
||||
const token = tokenData.match('rresp","(.+?)"'); |
||||
return token ? token[1] : null; |
||||
} |
||||
|
||||
interface IEmbedRes { |
||||
link: string; |
||||
sources: []; |
||||
tracks: []; |
||||
type: string; |
||||
} |
||||
|
||||
interface IStreamData { |
||||
status: string; |
||||
message: string; |
||||
type: string; |
||||
token: string; |
||||
result: |
||||
| { |
||||
Original: { |
||||
label: string; |
||||
file: string; |
||||
url: string; |
||||
}; |
||||
} |
||||
| { |
||||
label: string; |
||||
size: number; |
||||
url: string; |
||||
}[]; |
||||
} |
||||
|
||||
interface ISubtitles { |
||||
url: string; |
||||
lang: string; |
||||
} |
||||
|
||||
async function fetchStream(sourceId: string, captchaToken: string) { |
||||
const embedRes = await proxiedFetch<IEmbedRes>( |
||||
`${twoEmbedBase}/ajax/embed/play?id=${sourceId}&_token=${captchaToken}`, |
||||
{ |
||||
headers: { |
||||
Referer: twoEmbedBase, |
||||
}, |
||||
} |
||||
); |
||||
|
||||
// Link format: https://rabbitstream.net/embed-4/{data-id}?z=
|
||||
const rabbitStreamUrl = new URL(embedRes.link); |
||||
|
||||
const dataPath = rabbitStreamUrl.pathname.split("/"); |
||||
const dataId = dataPath[dataPath.length - 1]; |
||||
|
||||
// https://rabbitstream.net/embed/m-download/{data-id}
|
||||
const download = await proxiedFetch<any>( |
||||
`${rabbitStreamUrl.origin}/embed/m-download/${dataId}`, |
||||
{ |
||||
headers: { |
||||
referer: twoEmbedBase, |
||||
}, |
||||
} |
||||
); |
||||
|
||||
const downloadPage = new DOMParser().parseFromString(download, "text/html"); |
||||
|
||||
const streamlareEl = Array.from( |
||||
downloadPage.querySelectorAll(".dls-brand") |
||||
).find((el) => el.textContent?.trim() === "Streamlare"); |
||||
if (!streamlareEl) throw new Error("Unable to find streamlare element"); |
||||
|
||||
const streamlareUrl = |
||||
streamlareEl.nextElementSibling?.querySelector("a")?.href; |
||||
if (!streamlareUrl) throw new Error("Unable to parse streamlare url"); |
||||
|
||||
const subtitles: ISubtitles[] = []; |
||||
const subtitlesDropdown = downloadPage.querySelectorAll( |
||||
"#user_menu .dropdown-item" |
||||
); |
||||
subtitlesDropdown.forEach((item) => { |
||||
const url = item.getAttribute("href"); |
||||
const lang = item.textContent?.trim().replace("Download", "").trim(); |
||||
if (url && lang) subtitles.push({ url, lang }); |
||||
}); |
||||
|
||||
const streamlare = await proxiedFetch<any>(streamlareUrl); |
||||
|
||||
const streamlarePage = new DOMParser().parseFromString( |
||||
streamlare, |
||||
"text/html" |
||||
); |
||||
|
||||
const csrfToken = streamlarePage |
||||
.querySelector("head > meta:nth-child(3)") |
||||
?.getAttribute("content"); |
||||
|
||||
if (!csrfToken) throw new Error("Unable to find CSRF token"); |
||||
|
||||
const videoId = streamlareUrl.match("/[ve]/([^?#&/]+)")?.[1]; |
||||
if (!videoId) throw new Error("Unable to get streamlare video id"); |
||||
|
||||
const streamRes = await proxiedFetch<IStreamData>( |
||||
`${new URL(streamlareUrl).origin}/api/video/download/get`, |
||||
{ |
||||
method: "POST", |
||||
body: JSON.stringify({ |
||||
id: videoId, |
||||
}), |
||||
headers: { |
||||
"X-Requested-With": "XMLHttpRequest", |
||||
"X-CSRF-Token": csrfToken, |
||||
}, |
||||
} |
||||
); |
||||
|
||||
if (streamRes.message !== "OK") throw new Error("Unable to fetch stream"); |
||||
|
||||
const streamData = Array.isArray(streamRes.result) |
||||
? streamRes.result[0] |
||||
: streamRes.result.Original; |
||||
if (!streamData) throw new Error("Unable to get stream data"); |
||||
|
||||
const followStream = await rawProxiedFetch(streamData.url, { |
||||
method: "HEAD", |
||||
referrer: new URL(streamlareUrl).origin, |
||||
}); |
||||
|
||||
const finalStreamUrl = followStream.headers.get("X-Final-Destination"); |
||||
return { url: finalStreamUrl, subtitles }; |
||||
} |
||||
|
||||
registerProvider({ |
||||
id: "2embed", |
||||
displayName: "2Embed", |
||||
rank: 125, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
async scrape({ media, episode, progress }) { |
||||
let embedUrl = `${twoEmbedBase}/embed/tmdb/movie?id=${media.tmdbId}`; |
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) { |
||||
const seasonNumber = media.meta.seasonData.number; |
||||
const episodeNumber = media.meta.seasonData.episodes.find( |
||||
(e) => e.id === episode |
||||
)?.number; |
||||
|
||||
embedUrl = `${twoEmbedBase}/embed/tmdb/tv?id=${media.tmdbId}&s=${seasonNumber}&e=${episodeNumber}`; |
||||
} |
||||
|
||||
const embed = await proxiedFetch<any>(embedUrl); |
||||
progress(20); |
||||
|
||||
const embedPage = new DOMParser().parseFromString(embed, "text/html"); |
||||
|
||||
const pageServerItems = Array.from( |
||||
embedPage.querySelectorAll(".item-server") |
||||
); |
||||
const pageStreamItem = pageServerItems.find((item) => |
||||
item.textContent?.includes("Vidcloud") |
||||
); |
||||
|
||||
const sourceId = pageStreamItem |
||||
? pageStreamItem.getAttribute("data-id") |
||||
: null; |
||||
if (!sourceId) throw new Error("Unable to get source id"); |
||||
|
||||
const siteKey = embedPage |
||||
.querySelector("body") |
||||
?.getAttribute("data-recaptcha-key"); |
||||
if (!siteKey) throw new Error("Unable to get site key"); |
||||
|
||||
const captchaToken = await fetchCaptchaToken(siteKey); |
||||
if (!captchaToken) throw new Error("Unable to fetch captcha token"); |
||||
progress(35); |
||||
|
||||
const stream = await fetchStream(sourceId, captchaToken); |
||||
if (!stream.url) throw new Error("Unable to find stream url"); |
||||
|
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
streamUrl: stream.url, |
||||
quality: MWStreamQuality.QUNKNOWN, |
||||
type: MWStreamType.MP4, |
||||
captions: stream.subtitles.map((sub) => { |
||||
return { |
||||
langIso: sub.lang, |
||||
url: `https://cc.2cdns.com${new URL(sub.url).pathname}`, |
||||
type: MWCaptionType.VTT, |
||||
}; |
||||
}), |
||||
}, |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
|
||||
import { proxiedFetch } from "../helpers/fetch"; |
||||
import { registerProvider } from "../helpers/register"; |
||||
import { MWStreamQuality, MWStreamType } from "../helpers/streams"; |
||||
import { MWMediaType } from "../metadata/types"; |
||||
|
||||
const sflixBase = "https://sflix.video"; |
||||
|
||||
registerProvider({ |
||||
id: "sflix", |
||||
displayName: "Sflix", |
||||
rank: 50, |
||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||
async scrape({ media, episode, progress }) { |
||||
let searchQuery = `${media.meta.title} `; |
||||
|
||||
if (media.meta.type === MWMediaType.MOVIE) |
||||
searchQuery += media.meta.year ?? ""; |
||||
|
||||
if (media.meta.type === MWMediaType.SERIES) |
||||
searchQuery += `S${String(media.meta.seasonData.number).padStart( |
||||
2, |
||||
"0" |
||||
)}`;
|
||||
|
||||
const search = await proxiedFetch<any>( |
||||
`/?s=${encodeURIComponent(searchQuery)}`, |
||||
{ |
||||
baseURL: sflixBase, |
||||
} |
||||
); |
||||
const searchPage = new DOMParser().parseFromString(search, "text/html"); |
||||
|
||||
const moviePageUrl = searchPage |
||||
.querySelector(".movies-list .ml-item:first-child a") |
||||
?.getAttribute("href"); |
||||
if (!moviePageUrl) throw new Error("Movie does not exist"); |
||||
|
||||
progress(25); |
||||
|
||||
const movie = await proxiedFetch<any>(moviePageUrl); |
||||
const moviePage = new DOMParser().parseFromString(movie, "text/html"); |
||||
|
||||
progress(45); |
||||
|
||||
let outerEmbedSrc = null; |
||||
if (media.meta.type === MWMediaType.MOVIE) { |
||||
outerEmbedSrc = moviePage |
||||
.querySelector("iframe") |
||||
?.getAttribute("data-lazy-src"); |
||||
} else if (media.meta.type === MWMediaType.SERIES) { |
||||
const series = Array.from(moviePage.querySelectorAll(".desc p a")).map( |
||||
(a) => ({ |
||||
title: a.getAttribute("title"), |
||||
link: a.getAttribute("href"), |
||||
}) |
||||
); |
||||
|
||||
const episodeNumber = media.meta.seasonData.episodes.find( |
||||
(e) => e.id === episode |
||||
)?.number; |
||||
|
||||
const targetSeries = series.find((s) => |
||||
s.title?.endsWith(String(episodeNumber).padStart(2, "0")) |
||||
); |
||||
if (!targetSeries) throw new Error("Episode does not exist"); |
||||
|
||||
outerEmbedSrc = targetSeries.link; |
||||
} |
||||
if (!outerEmbedSrc) throw new Error("Outer embed source not found"); |
||||
|
||||
progress(65); |
||||
|
||||
const outerEmbed = await proxiedFetch<any>(outerEmbedSrc); |
||||
const outerEmbedPage = new DOMParser().parseFromString( |
||||
outerEmbed, |
||||
"text/html" |
||||
); |
||||
|
||||
const embedSrc = outerEmbedPage |
||||
.querySelector("iframe") |
||||
?.getAttribute("src"); |
||||
if (!embedSrc) throw new Error("Embed source not found"); |
||||
|
||||
const embed = await proxiedFetch<string>(embedSrc); |
||||
|
||||
const streamUrl = embed.match(/file\s*:\s*"([^"]+\.mp4)"/)?.[1]; |
||||
if (!streamUrl) throw new Error("Unable to get stream"); |
||||
|
||||
return { |
||||
embeds: [], |
||||
stream: { |
||||
streamUrl, |
||||
quality: MWStreamQuality.Q1080P, |
||||
type: MWStreamType.MP4, |
||||
captions: [], |
||||
}, |
||||
}; |
||||
}, |
||||
}); |
@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
|
||||
import { useMemo } from "react"; |
||||
import { useLocation } from "react-router-dom"; |
||||
|
||||
export function useQueryParams() { |
||||
const loc = useLocation(); |
||||
|
||||
const queryParams = useMemo(() => { |
||||
// Basic absolutely-not-fool-proof URL query param parser
|
||||
const obj: Record<string, string> = Object.fromEntries( |
||||
new URLSearchParams(loc.search).entries() |
||||
); |
||||
|
||||
return obj; |
||||
}, [loc]); |
||||
|
||||
return queryParams; |
||||
} |
@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
|
||||
{ |
||||
"global": { |
||||
"name": "movie-web" |
||||
}, |
||||
"search": { |
||||
"loading_series": "Načítání Vašich oblíbených seriálů...", |
||||
"loading_movie": "Načítání Vašich oblíbených filmů...", |
||||
"loading": "Načítání...", |
||||
"allResults": "To je vše co máme!", |
||||
"noResults": "Nemohli jsme nic najít!", |
||||
"allFailed": "Nepodařilo se najít média, zkuste to znovu!", |
||||
"headingTitle": "Výsledky vyhledávání", |
||||
"bookmarks": "Záložky", |
||||
"continueWatching": "Pokračujte ve sledování", |
||||
"title": "Co si přejete sledovat?", |
||||
"placeholder": "Co si přejete sledovat?" |
||||
}, |
||||
"media": { |
||||
"movie": "Filmy", |
||||
"series": "Seriály", |
||||
"stopEditing": "Zastavit upravování", |
||||
"errors": { |
||||
"genericTitle": "Jejda, rozbilo se to!", |
||||
"failedMeta": "Nepovedlo se načíst meta", |
||||
"mediaFailed": "Nepodařilo se nám požádat o Vaše média, zkontrolujte své internetové připojení a zkuste to znovu.", |
||||
"videoFailed": "Při přehrávání požadovaného videa došlo k chybě. Pokud se tohle opakuje prosím nahlašte nám to na <0>Discord serveru</0> nebo na <1>GitHubu</1>." |
||||
} |
||||
}, |
||||
"seasons": { |
||||
"seasonAndEpisode": "S{{season}} E{{episode}}" |
||||
}, |
||||
"notFound": { |
||||
"genericTitle": "Nenalezeno", |
||||
"backArrow": "Zpátky domů", |
||||
"media": { |
||||
"title": "Nemohli jsme najít Vaše média.", |
||||
"description": "Nemohli jsme najít média o které jste požádali. Buďto jsme ho nemohli najít, nebo jste manipulovali s URL." |
||||
}, |
||||
"provider": { |
||||
"title": "Tento poskytovatel byl zakázán", |
||||
"description": "Měli jsme s tímto poskytovatelem problémy, nebo byl moc nestabilní na používání, a tak jsme ho museli zakázat." |
||||
}, |
||||
"page": { |
||||
"title": "Tuto stránku se nepodařilo najít", |
||||
"description": "Dívali jsme se všude: pod koši, ve skříni, za proxy, ale nakonec jsme nemohli najít stránku, kterou hledáte." |
||||
} |
||||
}, |
||||
"searchBar": { |
||||
"movie": "Film", |
||||
"series": "Seriál", |
||||
"Search": "Hledání" |
||||
}, |
||||
"videoPlayer": { |
||||
"findingBestVideo": "Hledáme pro Vás nejlepší video", |
||||
"noVideos": "Jejda, nemohli jsme žádné video najít", |
||||
"loading": "Načítání...", |
||||
"backToHome": "Zpátky domů", |
||||
"backToHomeShort": "Zpět", |
||||
"seasonAndEpisode": "S{{season}} E{{episode}}", |
||||
"timeLeft": "Zbývá {{timeLeft}}", |
||||
"finishAt": "Končí ve {{timeFinished, datetime}}", |
||||
"buttons": { |
||||
"episodes": "Epizody", |
||||
"source": "Zdroj", |
||||
"captions": "Titulky", |
||||
"download": "Stáhnout", |
||||
"settings": "Nastavení", |
||||
"pictureInPicture": "Obraz v obraze", |
||||
"playbackSpeed": "Rychlost přehrávání" |
||||
}, |
||||
"popouts": { |
||||
"back": "Zpět", |
||||
"sources": "Zdroje", |
||||
"seasons": "Sezóny", |
||||
"captions": "Titulky", |
||||
"playbackSpeed": "Rychlost přehrávání", |
||||
"customPlaybackSpeed": "Vlastní rychlost přehrávání", |
||||
"captionPreferences": { |
||||
"title": "Upravit", |
||||
"delay": "Zpoždení", |
||||
"fontSize": "Velikost", |
||||
"opacity": "Průhlednost", |
||||
"color": "Barva" |
||||
}, |
||||
"episode": "E{{index}} - {{title}}", |
||||
"noCaptions": "Žádné titulky", |
||||
"linkedCaptions": "Propojené titulky", |
||||
"customCaption": "Vlastní titulky", |
||||
"uploadCustomCaption": "Nahrát titulky", |
||||
"noEmbeds": "Nebyla nalezena žádná vložení pro tento zdroj", |
||||
|
||||
"errors": { |
||||
"loadingWentWong": "Něco se nepovedlo při načítání epizod pro {{seasonTitle}}", |
||||
"embedsError": "Něco se povedlo při načítání vložení pro tuhle věc, kterou máte tak rádi" |
||||
}, |
||||
"descriptions": { |
||||
"sources": "Jakého poskytovatele chcete použít?", |
||||
"embeds": "Vyberte video, které chcete sledovat", |
||||
"seasons": "Vyberte sérii, kterou chcete sledovat", |
||||
"episode": "Vyberte epizodu", |
||||
"captions": "Vyberte jazyk titulků", |
||||
"captionPreferences": "Upravte titulky tak, jak se Vám budou líbit", |
||||
"playbackSpeed": "Změňtě rychlost přehrávání" |
||||
} |
||||
}, |
||||
"errors": { |
||||
"fatalError": "Došlo k závažné chybě v přehrávači videa, prosím nahlašte ji na <0>Discord serveru</0> nebo na <1>GitHubu</1>." |
||||
} |
||||
}, |
||||
"settings": { |
||||
"title": "Nastavení", |
||||
"language": "Jazyk", |
||||
"captionLanguage": "Jazyk titulků" |
||||
}, |
||||
"v3": { |
||||
"newSiteTitle": "Je dostupná nová verze!", |
||||
"newDomain": "https://movie-web.app", |
||||
"newDomainText": "movie-web se brzy přesune na novou doménu: <0>https://movie-web.app</0>. Nezapomeňte si aktualizovat záložky, protože <1>stará stránka přestane fungovat {{date}}.</1>", |
||||
"tireless": "Pracovali jsme neúnavně na této nové aktualizaci, a tak doufáme, že se Vám bude líbit co jsme v posledních měsících kuchtili.", |
||||
"leaveAnnouncement": "Vezměte mě tam!" |
||||
}, |
||||
"casting": { |
||||
"casting": "Přehrávání do zařízení..." |
||||
}, |
||||
"errors": { |
||||
"offline": "Zkontrolujte své internetové připojení" |
||||
} |
||||
} |
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
{ |
||||
"global": { |
||||
"name": "movie-web" |
||||
}, |
||||
"search": { |
||||
"loading_series": "Auf der Suche nach deiner Lieblingsserie...", |
||||
"loading_movie": "Auf der Suche nach deinen Lieblingsfilmen...", |
||||
"loading": "Wird geladen...", |
||||
"allResults": "Das ist alles, was wir haben!", |
||||
"noResults": "Wir haben nichts gefunden!", |
||||
"allFailed": "Das Medium wurde nicht gefunden, bitte versuchen Sie es erneut!", |
||||
"headingTitle": "Suchergebnisse", |
||||
"bookmarks": "Favoriten", |
||||
"continueWatching": "Weiter ansehen", |
||||
"title": "Was willst du gucken?", |
||||
"placeholder": "Was willst du gucken?" |
||||
}, |
||||
"media": { |
||||
"movie": "Filme", |
||||
"series": "Serie", |
||||
"stopEditing": "Beenden die Bearbeitung", |
||||
"errors": { |
||||
"genericTitle": "Hoppla, etwas ist schiefgegangen!", |
||||
"failedMeta": "Metadaten konnten nicht geladen werden", |
||||
"mediaFailed": "Wir konnten die angeforderten Medien nicht abrufen.", |
||||
"videoFailed": "Beim Abspielen des angeforderten Videos ist ein Fehler aufgetreten. <0>Discord</0> Oder weiter <1>GitHub</1>." |
||||
} |
||||
}, |
||||
"seasons": { |
||||
"seasonAndEpisode": "S{{season}} E{{episode}}" |
||||
}, |
||||
"notFound": { |
||||
"genericTitle": "Nicht gefunden", |
||||
"backArrow": "Zurück zur Startseite", |
||||
"media": { |
||||
"title": "Das Medium konnte nicht gefunden werden", |
||||
"description": "Wir konnten die angeforderten Medien nicht finden." |
||||
}, |
||||
"provider": { |
||||
"title": "Dieser Anbieter wurde deaktiviert", |
||||
"description": "Wir hatten Probleme mit dem Anbieter oder er war zu instabil, sodass wir ihn deaktivieren mussten." |
||||
}, |
||||
"page": { |
||||
"title": "Diese Seite kann nicht gefunden werden", |
||||
"description": "Wir haben überall gesucht, aber am Ende konnten wir die gesuchte Seite nicht finden." |
||||
} |
||||
}, |
||||
"searchBar": { |
||||
"movie": "Film", |
||||
"series": "Serie", |
||||
"Search": "Suchen" |
||||
}, |
||||
"videoPlayer": { |
||||
"findingBestVideo": "Auf der Suche nach dem besten Video für Sie", |
||||
"noVideos": "Entschuldigung, wir konnten keine Videos finden", |
||||
"loading": "Wird geladen...", |
||||
"backToHome": "Zurück zur Startseite", |
||||
"backToHomeShort": "Rückmeldung", |
||||
"seasonAndEpisode": "S{{season}} E{{episode}}", |
||||
"timeLeft": "{{timeLeft}} verbleibend", |
||||
"finishAt": "Endet um {{timeFinished, datetime}}", |
||||
"buttons": { |
||||
"episodes": "Folgen", |
||||
"source": "Quelle", |
||||
"captions": "Untertitel", |
||||
"download": "Herunterladen", |
||||
"settings": "Einstellungen", |
||||
"pictureInPicture": "Bild-im-Bild", |
||||
"playbackSpeed": "Wiedergabegeschwindigkeit" |
||||
}, |
||||
"popouts": { |
||||
"back": "Zurück", |
||||
"sources": "Quellen", |
||||
"seasons": "Staffel", |
||||
"captions": "Untertitel", |
||||
"playbackSpeed": "Lesegeschwindigkeit", |
||||
"customPlaybackSpeed": "Benutzerdefinierte Wiedergabegeschwindigkeit", |
||||
"captionPreferences": { |
||||
"title": "Bearbeiten", |
||||
"delay": "Verzögerung", |
||||
"fontSize": "Größe", |
||||
"opacity": "Opazität", |
||||
"color": "Farbe" |
||||
}, |
||||
"episode": "E{{index}} - {{title}}", |
||||
"noCaptions": "Keine Untertitel", |
||||
"linkedCaptions": "Verbundene Untertitel", |
||||
"customCaption": "Benutzerdefinierte Untertitel", |
||||
"uploadCustomCaption": "Untertitel hochladen", |
||||
"noEmbeds": "Für diese Quelle wurde kein eingebetteter Inhalt gefunden", |
||||
"errors": { |
||||
"loadingWentWong": "Beim Laden der Folgen für {{seasonTitle}} ist ein Problem aufgetreten ", |
||||
"embedsError": "Beim Laden der eingebetteter Medien ist ein Problem aufgetreten" |
||||
}, |
||||
"descriptions": { |
||||
"sources": "Welchen Anbieter möchtest du nutzen?", |
||||
"embeds": "Wähle das Video aus, das du ansehen möchten", |
||||
"seasons": "Wähle die Staffel aus, die du sehen möchten", |
||||
"episode": "Wähle eine Folge aus", |
||||
"captions": "Wähle eine Untertitelsprache", |
||||
"captionPreferences": "Passe das Erscheinungsbild von Untertiteln an", |
||||
"playbackSpeed": "Wiedergabegeschwindigkeit ändern" |
||||
} |
||||
}, |
||||
"errors": { |
||||
"fatalError": "Der Videoplayer hat einen Fehler festgestellt, bitte melde ihn dem Server <0>Discord</0> Oder weiter <1>GitHub</1>." |
||||
} |
||||
}, |
||||
"settings": { |
||||
"title": "Einstellungen", |
||||
"language": "Sprache", |
||||
"captionLanguage": "Untertitelsprache" |
||||
}, |
||||
"v3": { |
||||
"newSiteTitle": "Neue Version verfügbar!", |
||||
"newDomain": "https://movie-web.app", |
||||
"newDomainText": "movie-web zieht in Kürze auf eine neue Domain um: <0>https://movie-web.app</0>. <1>Die alte Website funktioniert nicht mehr {{date}}.</1>", |
||||
"tireless": "Wir haben unermüdlich an diesem neuen Update gearbeitet und hoffen, dass dir gefällt, was wir in den letzten Monaten vorbereitet haben.", |
||||
"leaveAnnouncement": "Bring mich dahin!" |
||||
}, |
||||
"casting": { |
||||
"casting": "An Gerät übertragen..." |
||||
}, |
||||
"errors": { |
||||
"offline": "Internetverbindung ist instabil" |
||||
} |
||||
} |
@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
|
||||
{ |
||||
"global": { |
||||
"name": "movie-web" |
||||
}, |
||||
"search": { |
||||
"loading_series": "Fetchin' yer favorite series...", |
||||
"loading_movie": "Fetchin' yer favorite movies...", |
||||
"loadin'": "Loadin'...", |
||||
"allResults": "That be all we 'ave, me hearty!", |
||||
"noResults": "We couldn't find anythin' that matches yer search!", |
||||
"allFailed": "Failed t' find media, walk the plank and try again!", |
||||
"headingTitle": "Search results", |
||||
"bookmarks": "Treasure Maps", |
||||
"continueWatchin'": "Continue Watchin'", |
||||
"title": "Wha' be ye wantin' to watch, me matey?", |
||||
"placeholder": "Wha' be ye searchin' for?" |
||||
}, |
||||
"media": { |
||||
"movie": "Movie", |
||||
"series": "Series", |
||||
"stopEditin'": "Stop editin'", |
||||
"errors": { |
||||
"genericTitle": "Shiver me timbers! It broke!", |
||||
"failedMeta": "Ye can't trust the compass, failed to load meta", |
||||
"mediaFailed": "We failed t' request the media ye asked fer, check yer internet connection, or Davy Jones's locker awaits ye!", |
||||
"videoFailed": "Blimey! We encountered an error while playin' the video ye requested. If this keeps happening please report the issue to the <0>Discord server</0> or on <1>GitHub</1>." |
||||
} |
||||
}, |
||||
"seasons": { |
||||
"seasonAndEpisode": "S{{season}} E{{episode}}" |
||||
}, |
||||
"notFound": { |
||||
"genericTitle": "Ahoy! I see nothin' on the horizon.", |
||||
"backArrow": "Back to the port", |
||||
"media": { |
||||
"title": "Avast ye! Couldn't find that media", |
||||
"description": "We couldn't find the media ye requested. Either it's been scuttled or ye tampered with the URL, ye scallywag!" |
||||
}, |
||||
"provider": { |
||||
"title": "Walk the plank! This provider has been disabled", |
||||
"description": "We had issues wit' the provider or 'twas too unstable t' use, so we had t' disable it. Try another one, arrr!" |
||||
}, |
||||
"page": { |
||||
"title": "Avast ye! Couldn't find that page.", |
||||
"description": "Arrr! We searched every inch o' the vessel: from the bilge to the crow's nest, from the keel to the topmast, but avast! We couldn't find the page ye be lookin' fer, me heartie." |
||||
} |
||||
}, |
||||
"searchBar": { |
||||
"movie": "Movie", |
||||
"series": "Series", |
||||
"Search": "Search" |
||||
}, |
||||
"videoPlayer": { |
||||
"findingBestVideo": "Finding the best video fer ye, hoist the colors!", |
||||
"noVideos": "Blistering barnacles, couldn't find any videos fer ye. Ye need a better map!", |
||||
"loading": "Loading...", |
||||
"backToHome": "Back to the port, mates!", |
||||
"backToHomeShort": "Back", |
||||
"seasonAndEpisode": "S{{season}} E{{episode}}", |
||||
"timeLeft": "{{timeLeft}} left", |
||||
"finishAt": "Finish at {{timeFinished}}", |
||||
"buttons": { |
||||
"episodes": "Episodes", |
||||
"source": "Source", |
||||
"captions": "Captions", |
||||
"download": "Download", |
||||
"settings": "Settings", |
||||
"pictureInPicture": "Spyglass view", |
||||
"playbackSpeed": "Set sail!" |
||||
}, |
||||
"popouts": { |
||||
"back": "Avast ye, go back!", |
||||
"sources": "Wha' provider do ye want to use?", |
||||
"seasons": "Choose which season you wants to watch!", |
||||
"captions": "Select a subtitle language, me hearty!", |
||||
"playbackSpeed": "Change the speed of Blackbeard's ship!", |
||||
"customPlaybackSpeed": "Set a custom playback speed", |
||||
"captionPreferences": { |
||||
"title": "Customize yer captions", |
||||
"delay": "Delay", |
||||
"fontSize": "Size", |
||||
"opacity": "Opacity", |
||||
"color": "Color" |
||||
}, |
||||
"episode": "E{{index}} - {{title}}", |
||||
"noCaptions": "No captions, hoist the Jolly Roger!", |
||||
"linkedCaptions": "Linked captions, drop anchor!", |
||||
"customCaption": "Custom caption, arrr!", |
||||
"uploadCustomCaption": "Upload yer own caption!", |
||||
"noEmbeds": "No embeds we be found fer this source", |
||||
|
||||
"errors": { |
||||
"loadingWentWong": "Shiver me timbers! Somethin' went wrong loadin' the episodes fer {{seasonTitle}}", |
||||
"embedsError": "Blimey! Somethin' went wrong loadin' the embeds fer this thin' that ye like" |
||||
}, |
||||
"descriptions": { |
||||
"sources": "Wha' provider do ye wants to use?", |
||||
"embeds": "Choose which video to view", |
||||
"seasons": "Choose which season ye wants to watch", |
||||
"episode": "Pick an episode", |
||||
"captions": "Choose a subtitle language", |
||||
"captionPreferences": "Make subtitles look how ye wants it", |
||||
"playbackSpeed": "Change the playback speed" |
||||
} |
||||
}, |
||||
"errors": { |
||||
"fatalError": "Blow me down! The video player encounted a fatal error, please report it to the <0>Discord server</0> or on <1>GitHub</1>." |
||||
} |
||||
}, |
||||
"settings": { |
||||
"title": "Settings", |
||||
"language": "Language", |
||||
"captionLanguage": "Caption Language" |
||||
}, |
||||
"v3": { |
||||
"newSiteTitle": "New version now released!", |
||||
"newDomain": "https://movie-web.app", |
||||
"newDomainText": "movie-web will soon be movin' to a new domain: <0>https://movie-web.app</0>. Make sure to update all yer bookmarks as <1>the ole website will stop workin' on {{date}}.</1>", |
||||
"tireless": "We've worked tirelessly on this new update, we hope ye will enjoy wha' we've been cookin' up fer the past months.", |
||||
"leaveAnnouncement": "Take me thar!" |
||||
}, |
||||
"casting": { "casting": "Casting to device..." }, |
||||
"errors": { "offline": "Avast! Check yer internet connection" } |
||||
} |
@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
|
||||
{ |
||||
"global": { |
||||
"name": "movie-web" |
||||
}, |
||||
"search": { |
||||
"loading_series": "正在获取您最喜欢的连续剧……", |
||||
"loading_movie": "正在获取您最喜欢的影片……", |
||||
"loading": "载入中……", |
||||
"allResults": "以上是我们能找到的所有结果!", |
||||
"noResults": "我们找不到任何结果!", |
||||
"allFailed": "查找媒体失败,请重试!", |
||||
"headingTitle": "搜索结果", |
||||
"bookmarks": "书签", |
||||
"continueWatching": "继续观看", |
||||
"title": "您想看些什么?", |
||||
"placeholder": "您想看些什么?" |
||||
}, |
||||
"media": { |
||||
"movie": "电影", |
||||
"series": "连续剧", |
||||
"stopEditing": "退出编辑", |
||||
"errors": { |
||||
"genericTitle": "哎呀,出问题了!", |
||||
"failedMeta": "加载元数据失败", |
||||
"mediaFailed": "我们未能请求到您要求的媒体,检查互联网连接并重试。", |
||||
"videoFailed": "我们在播放您要求的视频时遇到了错误。如果错误持续发生,请向 <0>Discord 服务器</0>或 <1>GitHub</1> 提交问题报告。" |
||||
} |
||||
}, |
||||
"seasons": { |
||||
"seasonAndEpisode": "第{{season}}季 第{{episode}}集" |
||||
}, |
||||
"notFound": { |
||||
"genericTitle": "未找到", |
||||
"backArrow": "返回首页", |
||||
"media": { |
||||
"title": "无法找到媒体", |
||||
"description": "我们无法找到您请求的媒体。它可能已被删除,或您篡改了 URL" |
||||
}, |
||||
"provider": { |
||||
"title": "该内容提供者已被停用", |
||||
"description": "我们的提供者出现问题,或是太不稳定,导致无法使用,所以我们不得不将其停用。" |
||||
}, |
||||
"page": { |
||||
"title": "无法找到页面", |
||||
"description": "我们已经到处找过了:不管是垃圾桶下、橱柜里或是代理之后。但最终并没有发现您查找的页面。" |
||||
} |
||||
}, |
||||
"searchBar": { |
||||
"movie": "电影", |
||||
"series": "连续剧", |
||||
"Search": "搜索" |
||||
}, |
||||
"videoPlayer": { |
||||
"findingBestVideo": "正在为您探测最佳视频", |
||||
"noVideos": "哎呀,无法为您找到任何视频", |
||||
"loading": "载入中……", |
||||
"backToHome": "返回首页", |
||||
"backToHomeShort": "返回", |
||||
"seasonAndEpisode": "第{{season}}季 第{{episode}}集", |
||||
"timeLeft": "还剩余 {{timeLeft}}", |
||||
"finishAt": "在 {{timeFinished, datetime}} 结束", |
||||
"buttons": { |
||||
"episodes": "分集", |
||||
"source": "视频源", |
||||
"captions": "字幕", |
||||
"download": "下载", |
||||
"settings": "设置", |
||||
"pictureInPicture": "画中画", |
||||
"playbackSpeed": "播放速度" |
||||
}, |
||||
"popouts": { |
||||
"back": "返回", |
||||
"sources": "视频源", |
||||
"seasons": "分季", |
||||
"captions": "字幕", |
||||
"playbackSpeed": "播放速度", |
||||
"customPlaybackSpeed": "自定义播放速度", |
||||
"captionPreferences": { |
||||
"title": "自定义", |
||||
"delay": "延迟", |
||||
"fontSize": "尺寸", |
||||
"opacity": "透明度", |
||||
"color": "颜色" |
||||
}, |
||||
"episode": "第{{index}}集 - {{title}}", |
||||
"noCaptions": "没有字幕", |
||||
"linkedCaptions": "已链接字幕", |
||||
"customCaption": "自定义字幕", |
||||
"uploadCustomCaption": "上传字幕", |
||||
"noEmbeds": "未发现该视频源的嵌入内容", |
||||
"errors": { |
||||
"loadingWentWong": "加载 {{seasonTitle}} 的分集时出现了一些问题", |
||||
"embedsError": "为您喜欢的这一东西加载嵌入内容时出现了一些问题" |
||||
}, |
||||
"descriptions": { |
||||
"sources": "您想使用哪个内容提供者?", |
||||
"embeds": "选择要观看的视频", |
||||
"seasons": "选择您要观看的季", |
||||
"episode": "选择一个分集", |
||||
"captions": "选择字幕语言", |
||||
"captionPreferences": "让字幕看起来如您所想", |
||||
"playbackSpeed": "改变播放速度" |
||||
} |
||||
}, |
||||
"errors": { |
||||
"fatalError": "视频播放器遇到致命错误,请向 <0>Discord 服务器</0>或 <1>GitHub</1> 报告。" |
||||
} |
||||
}, |
||||
"settings": { |
||||
"title": "设置", |
||||
"language": "语言", |
||||
"captionLanguage": "字幕语言" |
||||
}, |
||||
"v3": { |
||||
"newSiteTitle": "新的版本现已发布!", |
||||
"newDomain": "https://movie-web.app", |
||||
"newDomainText": "movie-web 将很快转移到新的域名:<0>https://movie-web.app</0>。请确保已经更新全部书签链接,<1>旧网站将于 {{date}} 停止工作。</1>", |
||||
"tireless": "为了这一新版本,我们不懈努力,希望您会喜欢我们在过去几个月中所做的一切。", |
||||
"leaveAnnouncement": "请带我去!" |
||||
}, |
||||
"casting": { |
||||
"casting": "正在投射到设备……" |
||||
}, |
||||
"errors": { |
||||
"offline": "检查您的互联网连接" |
||||
} |
||||
} |
@ -1,15 +0,0 @@
@@ -1,15 +0,0 @@
|
||||
.popout-wrapper ::-webkit-scrollbar-track { |
||||
background-color: transparent; |
||||
} |
||||
|
||||
.popout-wrapper ::-webkit-scrollbar-thumb { |
||||
background-color: theme("colors.denim-500"); |
||||
border: 5px solid transparent; |
||||
border-left: 0; |
||||
background-clip: content-box; |
||||
} |
||||
|
||||
.popout-wrapper ::-webkit-scrollbar { |
||||
/* For some reason the styles don't get applied without the width */ |
||||
width: 13px; |
||||
} |
Loading…
Reference in new issue