Refactor .gitignore to streamline ignored files and enhance clarity. Update CLAUDE.md to improve unit test instructions and add coverage reporting details. Revise README.md for better project overview and deployment instructions. Refactor ChannelEndpoints and StreamingEndpoints to utilize SegmentFiles for file resolution, improving code maintainability. Remove unused JunctionHandlers and update DependencyInjection for cleaner service registration. Enhance media processing services for better job handling and error management. Update frontend API types for consistency and clarity.
build / backend (push) Successful in 1m28s
build / frontend (push) Failing after 31s
tests / backend-tests (push) Canceled after 0s
sonar / analyze (push) Successful in 4m39s

This commit is contained in:
Leonid Pershin
2026-07-26 20:43:38 +03:00
parent f36dbfa9cb
commit 205672b77d
77 changed files with 3292 additions and 3102 deletions
+677
View File
@@ -0,0 +1,677 @@
/** Словарь локали «en». Плоская правка: ключи обеих локалей обязаны совпадать. */
export const en = {
appName: 'TeleWave',
nav: {
dashboard: 'On Air',
admin: 'Admin',
settings: 'Settings',
login: 'Log in',
register: 'Sign up',
logout: 'Log out',
},
theme: { light: 'Light', dark: 'Dark', system: 'System' },
lang: { ru: 'RU', en: 'EN' },
common: {
save: 'Save',
cancel: 'Cancel',
retry: 'Retry',
delete: 'Delete',
create: 'Create',
loading: 'Loading…',
error: 'Something went wrong',
search: 'Search',
actions: 'Actions',
prevPage: 'Previous page',
nextPage: 'Next page',
yes: 'Yes',
no: 'No',
},
home: {
title: 'TELEWAVE',
subtitle: 'BROADCAST CHANNEL GRID',
tagline: 'Your channels. Your broadcast. Anytime.',
cta: 'Go on air',
ctaRegister: 'Create account',
},
auth: {
userName: 'Username',
password: 'Password',
loginTitle: 'Sign in',
loginSubtitle: 'Enter your credentials to access the channel grid',
registerTitle: 'New viewer',
registerSubtitle: 'Create an account to set up your channel grid',
submitLogin: 'Log in',
submitRegister: 'Sign up',
noAccount: "Don't have an account?",
haveAccount: 'Already have an account?',
registrationClosed: 'Registration is closed',
registrationClosedHint: 'Public registration is disabled. An administrator can create an account for you.',
invalidCredentials: 'Invalid username or password',
userNameTaken: 'This username is already taken',
blocked: 'Account blocked by an administrator',
genericError: 'Could not sign in. Please try again',
},
air: {
now: 'Now',
next: 'Up next',
ad: 'Ad',
bumper: 'Bumper',
episode: 'Episode',
volume: 'Volume',
live: 'Live',
noChannels: 'No channels available yet. Check back later.',
offline: 'This channel is off the air',
offlineHint: 'No schedule or content yet. Check back later.',
retry: 'Retry',
numbersHint: '↑ / ↓ — switch channels by number',
},
settings: {
title: 'Account settings',
changeUserName: 'Change username',
newUserName: 'New username',
changePassword: 'Change password',
currentPassword: 'Current password',
newPassword: 'New password',
dangerZone: 'Danger zone',
deleteAccount: 'Delete account',
deleteAccountConfirm: 'The account and all its data will be permanently deleted. Continue?',
saved: 'Saved',
},
admin: {
groups: {
title: 'Groups',
hint: 'A group is what may go on air. Grid slots reference it; the strategy picks an element from it.',
name: 'Name',
description: 'Description',
items: 'Items',
units: 'Units',
duration: 'Runtime',
hoursShort: 'h',
minutesShort: 'min',
hasFilter: 'has rule',
composition: 'Composition',
empty: 'Group is empty',
orderHint: 'Drag to set the order — sequential strategies follow it.',
showAdvanced: 'Advanced',
hideAdvanced: 'Hide',
find: 'Find matches',
addFound: 'Add found',
found: 'Found: {{total}}, new: {{fresh}}',
added: 'Items added: {{count}}',
alreadyIn: 'already in group',
elementKinds: { Show: 'Show', Collection: 'Collection' },
filter: {
title: 'Selection rule',
hint: 'The rule only finds candidates — the group composition stays an explicit list.',
elementKinds: 'What to search',
showKinds: 'Show type',
genres: 'Genres',
genresHint: 'Any of the checked ones.',
maxAudience: 'No stricter than',
audienceHint: 'Categories are ordered by strictness: kids → family → … → adult.',
year: 'Year',
unitMinutes: 'Unit runtime, min',
unitMinutesHint:
'Average episode or film length. Items without ready assets are not filtered out.',
from: 'from',
to: 'to',
any: 'any',
},
},
collections: {
title: 'Collections',
hint: 'A franchise is an ordered set of films played as a single unit.',
name: 'Name',
description: 'Description',
parts: 'Parts',
units: 'Units',
addShow: 'Add show',
empty: 'Collection is empty',
orderHint: 'Drag to set the order of the parts — that is the order they air in.',
},
interstitials: {
title: 'Clips',
hint: 'Ads, promos and jingles. Drag clips into the block builder or a group on the right — a block is saved as a collection and airs as a whole.',
upload: 'Upload clips',
name: 'Name',
duration: 'Duration',
empty: 'No clips yet',
noAsset: 'No file',
blocks: 'Blocks',
noBlocks: 'No blocks yet',
clipsCount: '{{count}} clips',
blockBuilder: 'Block builder',
blockName: 'Block name',
blockTotal: 'Block duration',
dropHint: 'Drop clips here',
groups: 'Clip groups',
pickGroup: 'Pick a group',
pickGroupFirst: 'Pick a group first',
dropToGroup: 'Drop a clip or a block here',
openGroup: 'Open group',
newGroupName: 'New group',
},
genres: {
title: 'Genres',
hint: 'Genre reference: content groups are built from it, provider metadata is mapped into it.',
name: 'Name',
slug: 'Key',
slugHint: 'Latin letters, no spaces — the genre is matched by it when the reference updates.',
aliases: 'Spellings',
aliasesHint:
'Comma-separated. Maps provider genres onto yours: tmdb:28, action, боевик.',
order: 'Order',
usage: 'Shows',
system: 'System',
create: 'New genre',
edit: 'Edit genre',
},
roles: {
title: 'Roles',
name: 'Name',
system: 'System',
create: 'New role',
rename: 'Rename',
},
users: {
title: 'Users',
userName: 'Username',
role: 'Role',
status: 'Status',
createdAt: 'Joined',
blocked: 'Blocked',
active: 'Active',
block: 'Block',
unblock: 'Unblock',
resetPassword: 'Password',
resetPasswordFor: 'Reset password: {{name}}',
newPassword: 'New password',
passwordReset: 'Password changed',
filterAll: 'All roles',
createTitle: 'Create user',
password: 'Password',
passwordHint: 'At least 8 characters, including a digit and an uppercase letter.',
create: 'Create',
created: 'User created',
},
media: {
title: 'Media',
upload: 'Upload',
manualButton: 'From manual folder',
manualTitle: 'Manual pick from the manual folder',
manualHint:
'Files in manual/ are not picked up by the scanner — select the ones you need and choose a show. Imported files leave the folder, just like from inbox.',
manualSelectAll: 'Select all',
manualSelected: 'Selected: {{count}}',
manualEmpty: 'The manual folder is empty',
regexPickHint: 'Click a number in the file name — a rule for all files is built from it:',
regexPickTitle: 'This is the episode number',
regexPresets: 'Ready-made:',
regexPresetNames: {
seriesWord: 'Серия N',
episodeWord: 'Эпизод N',
seasonEpisode: 'SxxEyy',
afterDash: 'after a dash',
firstNumber: 'first number',
},
regexClear: 'clear',
manualAlready: 'already in the library',
manualRoot: 'manual/ root',
manualRecognized: 'Recognized: {{count}} of {{total}}',
manualCleanupHint:
'Files leave the folder; siblings (subtitles, nfo) and the emptied folder are removed.',
manualTruncated: 'Showing the first 500 files — there are more in the folder.',
manualShow: 'Show',
manualDetected: 'Detected from the release name — check it and change if wrong.',
manualPickShow: 'Pick a show',
manualImport: 'Import into show',
manualImported: 'Files imported: {{count}}',
uploadToShow: 'Upload to show',
toShowTitle: 'Upload and add to show',
autoDetectHint:
'Each file is linked to the show whose original (or display) name appears in the release name, e.g. “The.Simpsons.S33E01…” → The Simpsons.',
toShowLibrary: 'To library',
toShowSeason: 'Season (manual)',
toShowAuto: 'auto',
toShowRegex: 'Episode regex',
toShowRegexInvalid: 'invalid regex',
toShowHint:
'Season and regex are optional: numbers are usually detected automatically (see below). Regex: 1 group = episode, 2 groups = season and episode. Example: ^(\\d+) for “01. Title.mkv”.',
toShowPreview: 'What we detect',
toShowMatched: 'show detected for {{matched}} of {{total}}',
applyToAll: 'Set for all…',
toShowUnknown: '—',
toShowConfirm: 'Upload and add',
uploadedCount: 'Uploaded files: {{count}}',
uploadingCount: 'Uploading {{done}}/{{total}}',
cancelAll: 'Cancel all uploads',
skippedDuplicates: 'Skipped duplicates: {{count}}',
filterActive: 'Active',
filterAll: 'All',
name: 'File',
status: 'Status',
duration: 'Duration',
resolution: 'Resolution',
processingTime: 'Processing time',
empty: 'No uploaded files yet',
statuses: {
Pending: 'Queued',
Processing: 'Processing',
Ready: 'Ready',
Failed: 'Failed',
},
stats: {
queued: 'Currently queued',
queuedShort: 'Queued',
processing: 'Currently processing',
processingShort: 'Processing',
average: 'Average processing time (recent)',
averageShort: 'Avg time',
},
},
gallery: {
title: 'Gallery',
upload: 'Upload',
empty: 'No images in this category yet',
pickHint: 'Pick an image or upload a new one',
browseHint: 'All app images by category',
sort: {
newest: 'Newest first',
oldest: 'Oldest first',
nameAsc: 'Name: AZ',
nameDesc: 'Name: ZA',
},
categories: {
Library: 'Library',
ShowPoster: 'Show posters',
EpisodeStill: 'Episode stills',
BumperBackground: 'Bumper backgrounds',
},
},
shows: {
title: 'Shows',
name: 'Name',
originalName: 'Original name (eng)',
kind: 'Kind',
kinds: { Series: 'Series', Single: 'Movie', Interstitial: 'Clip' },
audience: 'Category',
audiences: {
Kids: 'Kids',
Family: 'Family',
Teen: 'Teen',
General: 'General',
Adult: 'Adult',
},
seasons: 'Seasons',
loadedSeasons: 'Loaded seasons',
genre: 'Genre',
allGenres: 'All genres',
genresEmpty: 'No genres set',
genresEdit: 'Genres',
genresHint:
'Pick the genres of the show. The primary one is listed; all of them are used for selection.',
genrePrimary: 'primary',
inCollections: 'Part of collections',
episodes: 'Episodes',
episode: 'Episode',
noEpisodes: 'No episodes yet',
filterAssets: 'Filter by name, e.g. Family.Guy.S16',
selectAll: 'Select all',
deselectAll: 'Clear',
addSelected: 'Add selected',
noMatches: 'No matching files',
addedCount: 'Episodes added: {{count}}',
candidatesTruncated:
'Not all files are shown (too many ready assets) — narrow the filter to see the rest.',
},
channels: {
number: 'Number',
numberPlaceholder: 'not set',
utcOffset: 'Time zone, h',
utcOffsetHint:
'Whole hours: 3 is Moscow, 0 is UTC, 5 is New York. The whole grid and the schedule are expressed in this time.',
dayStart: 'Broadcast day starts',
dayStartHint: 'The night block before this time belongs to the previous day.',
disabled: 'disabled',
layers: 'Layers',
layersHint: 'Higher priority wins. The background layer sits below all and fills gaps.',
background: 'background',
newLayerName: 'New layer',
addSlotHere: 'Add slot',
newSlot: 'New slot',
editSlot: 'Slot',
slotTitle: 'Block title',
slotStart: 'Start',
slotDuration: 'Budget, min',
weekday: 'Weekday',
everyDay: 'every day',
daypart: 'Daypart',
slotKind: 'Slot type',
group: 'Group',
pickGroup: 'pick a group',
strategy: 'Strategy',
cooldownDays: 'Cooldown, days',
cooldownHint: 'Skip what already aired within this period.',
blockMode: 'Block',
blockValue: 'Value',
overflow: 'If it does not fit',
overflowHint: 'What to do with an element that exceeds the slot budget.',
repeatDaysAgo: 'Days ago',
repeatTime: 'Time',
repeatDuration: 'Duration, min',
anchor: 'Anchor',
anchorHint:
'An anchor keeps the start hard: nothing that would cross it is started. Snapping is soft — it shifts the start to a round time if that fits the allowance.',
maxDrift: 'Allowance, min',
snap: 'Snap',
snapOff: 'off',
bumperConditionsHint:
'How often a bumper is inserted and on which transitions is a junction-element condition, not a channel setting.',
resizeSlot: 'Drag the edge to change duration',
copyDay: 'Copy day',
copyDayFrom: 'Copy {{day}} to:',
copy: 'Copy',
layerVisible: 'Show layer',
layerName: 'Layer name',
layerApplicability: 'When it applies',
applicabilityHint:
'Sections are OR-ed: the layer applies when the date matches at least one condition. Nothing filled in — it always applies.',
applicabilityWeekdays: 'Weekdays',
applicabilityDateRanges: 'Date ranges',
applicabilityAnnual: 'Yearly (month / day)',
applicabilityDates: 'Specific dates',
applicabilityNone: 'not set',
showForDate: 'Grid for date',
allDates: 'All layers',
tabs: {
grid: 'Grid',
rules: 'Rules',
junctions: 'Junctions',
bumpers: 'Bumpers',
viewer: 'Viewer',
settings: 'Settings',
air: 'On air',
},
noTemplate: 'The channel grid failed to load',
createTemplate: 'Create the grid',
createTemplateHint:
'An empty grid with a background layer appears — then add layers and slots.',
rules: 'Candidate rules',
rulesHint:
'Hard filters: they cut out what is not allowed before the draw. Like grid edits, they do not move the air — apply to take effect.',
audienceWindows: 'Family hours',
noAudienceWindows: 'No windows — the age is not limited.',
audienceWindowsHint:
'Inside a window only content no stricter than the chosen category airs. A window may cross midnight. Content with no category is never dropped.',
from: 'From',
to: 'To',
maxAudience: 'No stricter than',
repeatLimit: 'Repeat cap',
repeatWindowDays: 'Window, days',
repeatMax: 'At most, times',
repeatLimitHint:
'Counted against the already recorded tape. If every candidate hits the cap the slot is still filled: empty air is worse than an early repeat.',
preview: 'Preview',
previewHide: 'Hide preview',
previewHint: 'A run against the current rules: nothing is written, slot cursors do not move.',
previewDays_one: '{{count}} day',
previewDays_other: '{{count}} days',
previewTabs: { programme: 'Programme', tape: 'Tape', problems: 'Problems' },
noProblems: 'No problems',
andMore: 'and {{count}} more',
heatmap: 'Repeats: show × day',
heatmapTotal: 'total',
issues: 'Checks: {{errors}} errors, {{warnings}} warnings',
goToSlot: 'to slot',
issueKinds: {
GroupEmpty: 'Empty group',
GroupTooSmall: 'Too little content',
GridGap: 'Gap in the grid',
SlotOverlap: 'Slots overlap',
CooldownUnreachable: 'Unreachable cooldown',
AudienceConflict: 'Age conflict',
GroupMissing: 'No group selected',
},
viewer: 'How viewers see it',
viewerHint:
'Overlays are drawn on the client on top of the picture — the video is not re-encoded. Everything is off by default.',
logo: 'Logo',
noLogo: 'none',
pickLogo: 'Pick a logo',
logoCorner: 'Corner',
logoOpacity: 'Opacity',
corners: {
TopLeft: 'Top left',
TopRight: 'Top right',
BottomLeft: 'Bottom left',
BottomRight: 'Bottom right',
},
showClock: 'Show a clock',
analogFilter: 'Analog filter',
analogFilterHint: 'Strength 0..1. Zero is off; it is very easy to overdo.',
whyHere: 'Why is this here',
priority: 'priority',
traceLayer: 'Layer',
traceSlot: 'Slot',
traceGroup: 'Group',
traceCollection: 'Collection',
traceStrategy: 'Strategy',
traceJunction: 'Breaks',
traceDrift: 'drift {{minutes}} min',
traceSnapped: 'start snapped',
traceCooldown: 'cooldown {{days}} d.',
traceCandidates: 'candidates after cooldown: {{count}}',
diffSummary: 'Affects {{total}} entries, {{changed}} will change',
diffSoon: 'Entries changing within 24 hours: {{count}}',
diffNoChanges: 'The air will not change',
copyTemplate: 'Copy a grid from a channel',
pickSourceChannel: 'Pick the source channel',
copyHere: 'Copy here',
copyTemplateHint:
'Layers, slots, junctions and rules of the chosen channel replace this channel grid. Groups are shared and not copied.',
copyTemplateConfirm:
'This channel grid will be replaced with the chosen channel grid. Continue?',
templateCopied: 'Copied: {{layers}} layers, {{slots}} slots',
copyDroppedBumpers: 'Breaks left without a bumper block: {{count}} — set them up by hand',
postChecks: 'Post-checks',
breakLimit: 'Breaks per hour cap, min',
genreShare: 'Genre share per day cap, %',
fallbackShare: 'Background share cap, %',
postChecksHint:
'Post-checks run against the finished tape and only warn — nothing is replanned.',
previewKinds: {
Program: 'Programme',
Fallback: 'Background',
SignOff: 'Sign-off',
Ad: 'Ad',
Promo: 'Promo',
Bumper: 'Bumper',
},
previewLoad: 'Breaks per hour, peak — {{peak}} min',
junctions: 'Junctions',
junctionsHint:
'What plays between programmes: ads, promos, bumpers. A slot may pick its own junction, otherwise the default one is used.',
defaultJunction: 'Default junction',
noJunction: 'no junction',
newJunctionName: 'New junction',
addJunctionElement: '+ break',
junctionEmpty: 'empty',
junctionFrom: 'end',
junctionTo: 'start',
junctionElement: 'Break',
junctionKind: 'Kind',
junctionKinds: { Ad: 'Ad', Promo: 'Promo', Bumper: 'Bumper', Filler: 'Filler' },
junctionAmountMode: 'Measured in',
junctionAmountModes: { Count: 'Units', Duration: 'Minutes' },
junctionCount: 'How many units',
junctionMinutes: 'How many minutes',
junctionAmountHint:
'For a mixed group (clips and ready-made blocks) count in minutes: one "unit" there is either a clip or a whole block.',
junctionRequired: 'Required — never dropped when time runs short',
junctionOnlyOnChange: 'Only when the show changes',
junctionMinInterval: 'No more often than once per, min',
junctionMinIntervalHint: '0 — no limit.',
junctionBetween: 'Junction inside the slot',
junctionAfter: 'Junction after the slot',
junctionDefault: 'default',
bumperTemplate: 'Bumper block',
pickBumperTemplate: 'pick a block',
minutesShort: ' min',
pendingChanges: 'Rules changed — the air still follows the old ones.',
apply: 'Apply',
applied: 'Air rebuilt, entries: {{count}}',
weekdays: {
0: 'Sun',
1: 'Mon',
2: 'Tue',
3: 'Wed',
4: 'Thu',
5: 'Fri',
6: 'Sat',
},
dayparts: { Morning: 'Morning', Day: 'Day', Prime: 'Prime', Night: 'Night' },
slotKinds: { Content: 'Content', Repeat: 'Repeat', SignOff: 'Sign-off' },
blockModes: { Count: 'Units', Duration: 'Minutes', FillSlot: 'Whole slot' },
overflows: {
ContinueNext: 'Continue next time',
ExtendSlot: 'Extend the slot',
SkipIfNotFits: 'Do not start',
},
strategies: {
Sequential: 'In order',
RandomWithCooldown: 'Random with cooldown',
Fixed: 'Fixed element',
},
warnings: {
SlotEmpty: 'Slot produced no content',
DriftExceeded: 'Start drifted past the allowance',
CooldownExhausted: 'Cooldown ruled out every candidate',
RepeatSourceEmpty: 'Nothing to repeat',
FallbackEmpty: 'Nothing to fill pauses with',
CandidatesFiltered: 'The age cap ruled out every candidate',
BreakLimitExceeded: 'Breaks in an hour exceed the cap',
GenreShareExceeded: 'Genre share above the norm',
FallbackShareExceeded: 'Background share above the norm',
},
title: 'Channels',
name: 'Name',
slug: 'Slug',
state: 'State',
enabled: 'On air',
enabledLabel: 'Channel on air',
settings: 'Settings',
bumpers: 'TV bumpers',
bumpersLabel: 'Transition bumpers',
bumpersHint: 'Short “Now / Next” bumper between different shows',
bumperSelection: 'Block selection',
bumperSelectionRandom: 'Random',
bumperSelectionWeighted: 'Weighted random',
bumperSelectionAlwaysFirst: 'Always first',
bumperFont: 'Font',
bumperFontSans: 'Sans',
bumperFontSerif: 'Serif',
bumperNowLabel: '“Now” label',
bumperNextLabel: '“Next” label',
bumperBg: 'Background (color 1)',
bumperBg2: 'Background (color 2)',
bumperAccent: 'Accent',
bumperText: 'Text',
bumperTemplates: 'Bumper blocks',
bumperTemplatesHint:
'Each block has its own sound and style. The first block is the default and cannot be removed. Bumper length follows the sound length.',
bumperAddTemplate: 'Add block',
bumperTemplateName: 'Name',
bumperVariants: 'Sub-blocks (text)',
bumperVariantsHint:
'Different text over the same music and style. Each sub-block has its own show rule.',
bumperAddVariant: 'Add text',
bumperVariantName: 'Name',
bumperTextKind: 'Text mode',
bumperKindNowNext: 'Now / Next',
bumperKindFree: 'Free text',
bumperLine1: 'Line 1',
bumperLine2: 'Line 2',
bumperTrigger: 'Show on',
bumperTriggerOnShowChange: 'Show change',
bumperTriggerBetweenEpisodes: 'Between episodes',
bumperTriggerBoth: 'Both',
bumperVariantWeight: 'Weight',
bumperVariantWeightHint: 'For the “weighted random” strategy: higher = more often (0 — never picked)',
bumperDefault: 'default',
bumperSeconds: 's',
bumperDefaultDuration: '≈8 s (jingle)',
bumperAudio: 'Sound',
bumperAudioHint: 'Bumper sound; otherwise a synthesized jingle',
bumperPreview: 'Render samples',
bumperPreviewRendering: 'Rendering…',
bumperPreviewHint: 'Samples of all sub-blocks with sound and animation (example show names). Uses saved settings.',
bumperBackground: 'Background image',
bumperBackgroundHint: 'Background image; otherwise the show poster or a gradient',
bumperBackgroundPick: 'Pick from gallery',
bumperFileLoaded: 'loaded',
bumperFileDefault: 'default',
bumperUpload: 'Upload',
bumperReset: 'Reset',
filler: 'Filler',
noFiller: 'No filler',
noSchedule: 'Schedule not built yet',
},
maintenance: {
title: 'Maintenance',
warning: 'These actions are irreversible — data and files are deleted permanently.',
clearMedia: 'Clear all media',
clearMediaHint: 'Deletes all uploaded files and their segments, plus channel schedules.',
confirmClearMedia: 'Permanently delete ALL media files?',
clearShowMedia: 'Delete show media',
clearShowMediaHint: "Deletes files of the selected show's episodes and clears its episodes.",
confirmClearShowMedia: 'Permanently delete all media of the selected show?',
pickShow: 'Pick a show',
deleteShows: 'Delete all shows',
deleteShowsHint: 'Deletes all shows and their episodes. Media files stay in the library.',
confirmDeleteShows: 'Permanently delete ALL shows?',
doneCount: 'Deleted: {{count}}',
},
settings: {
title: 'Settings',
registration: 'Registration',
registrationHint:
'When off, new users cannot sign up themselves — only an administrator can create accounts.',
registrationLabel: 'Allow public registration',
channelNumbers: 'Switch channels by number',
channelNumbersHint:
'Viewers switch channels with the arrow keys, like on a TV set. The channel grid stays available regardless.',
preferredAudio: 'Preferred audio tracks',
preferredAudioHint:
'Comma-separated language codes in priority order (e.g. "rus, eng"). If a file has a track in one of these languages, it is picked during processing (by order); otherwise ffmpeg default. Applies to new processing.',
},
metadata: {
title: 'Metadata',
pickPoster: 'From gallery',
name: 'Name',
originalName: 'Original name (eng)',
originalNamePlaceholder: 'e.g. Family Guy',
originalNameHint: 'Metadata is looked up by this; screens still show the regular name.',
sourceLabel: 'Source',
searchBtn: 'Search',
nothingFound: 'Nothing found',
apply: 'Apply',
applied: 'Metadata applied',
overview: 'Overview',
year: 'Year',
clear: 'Clear',
noPoster: 'No poster',
refreshEpisodes: 'Refresh episodes',
refreshing: 'Refreshing…',
refreshedCount: 'Episodes updated: {{count}}',
findMissing: 'Find missing episodes',
missingTitle: 'Missing episodes',
missingNoSeasons: 'No loaded episodes with recognized numbers.',
seasonN: 'Season {{n}}',
loadedOf: 'loaded {{loaded}} of {{total}}',
missingUnknown: 'The source did not return the episode count for this season.',
missingNone: 'All episodes present.',
missingList: 'Missing',
},
},
}