yt-grabber 1.8.7 → 1.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yt-grabber",
3
- "version": "1.8.7",
3
+ "version": "1.8.8",
4
4
  "description": "Youtube Grabber - robust desktop application designed to retrieve multimedia from YouTube and YouTube Music services",
5
5
  "keywords": [
6
6
  "youtube",
@@ -1,5 +1,6 @@
1
1
  import {App} from "electron";
2
2
  import {forEach, groupBy, includes, indexOf, isNumber, keys, map, replace} from "lodash-es";
3
+ import moment from "moment";
3
4
 
4
5
  import {VideoType} from "./Media";
5
6
  import {TrackInfo, UrlType, YoutubeInfoResult} from "./Youtube";
@@ -36,6 +37,41 @@ export const getProcessArgs = () => {
36
37
  return filteredArgs;
37
38
  };
38
39
 
40
+ export const formatTime = (value: string) => {
41
+ const formatted = moment.duration(value, "seconds").format("HH:mm:ss", {trim: "left"});
42
+ const onlyTwoDigitsRegex = /^\d{2}$/;
43
+
44
+ if (onlyTwoDigitsRegex.test(formatted)) {
45
+ return `00:${formatted}`;
46
+ }
47
+
48
+ return formatted;
49
+ };
50
+
51
+ export const unformatTime = (value: string) => {
52
+ const onlyTwoDigitsRegex = /^\d{2}$/;
53
+ const atLeastTwoColonsRegex = /^([^:]*:){2}/;
54
+
55
+ if (onlyTwoDigitsRegex.test(value)) {
56
+ return moment.duration(`00:00:${value}`).asSeconds() + "";
57
+ }
58
+ if (atLeastTwoColonsRegex.test(value)) {
59
+ return moment.duration(`${value}`).asSeconds() + "";
60
+ }
61
+
62
+ return moment.duration(`00:${value}`).asSeconds() + "";
63
+ };
64
+
65
+ export const timeStringToNumber = (value: string) => {
66
+ const atLeastTwoColonsRegex = /^([^:]*:){2}/;
67
+
68
+ if (atLeastTwoColonsRegex.test(value)) {
69
+ return moment.duration(`${value}`).asSeconds();
70
+ }
71
+
72
+ return moment.duration(`00:${value}`).asSeconds();
73
+ };
74
+
39
75
  export const formatFileSize = (sizeInBytes: number, decimals = 2) => {
40
76
  if (!isNumber(sizeInBytes)) return "";
41
77
  if (sizeInBytes === 0) return "0 Bytes";
@@ -69,7 +69,7 @@ const getPostProcessorArgs = (track: TrackInfo, album: AlbumInfo) => {
69
69
  const title = track.title.replace(/"/g, "\\\"");
70
70
  const artist = album.artist.replace(/"/g, "\\\"");
71
71
  const albumTitle = album.title.replace(/"/g, "\\\"");
72
- return getCutsPostProcessorArgs() + `-metadata title="${title}" -metadata artist="${artist}" -metadata album="${albumTitle}" -metadata track="${track.playlist_autonumber}" -metadata date="${album.releaseYear}" -metadata release_year="${album.releaseYear}"`;
72
+ return getCutsPostProcessorArgs() + `-metadata title="${title}" -metadata artist="${artist}" -metadata album="${albumTitle}" -metadata track="${track.playlist_autonumber}/${album.tracksNumber}" -metadata date="${album.releaseYear}" -metadata release_year="${album.releaseYear}"`;
73
73
  }
74
74
 
75
75
  return getCutsPostProcessorArgs() + `-metadata title="${track.title}"`;
@@ -0,0 +1,21 @@
1
+ @import "../../../styles/mixins.styl"
2
+
3
+ .cut-modal {
4
+ .content {
5
+ justify-content: flex-start;
6
+
7
+ .add-button {
8
+ min-width: 0;
9
+ }
10
+
11
+ .delete-button {
12
+ min-width: 0;
13
+ }
14
+
15
+ .entry-content {
16
+ width: 100%;
17
+ justify-content: center;
18
+ align-items: center;
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,200 @@
1
+ import {filter, isEmpty, last, map, some, uniqueId} from "lodash-es";
2
+ import React, {KeyboardEvent, useState} from "react";
3
+ import {useTranslation} from "react-i18next";
4
+ import {NumberFormatBase} from "react-number-format";
5
+
6
+ import AddIcon from "@mui/icons-material/Add";
7
+ import DeleteForeverIcon from "@mui/icons-material/DeleteForever";
8
+ import {
9
+ Avatar, Grid, IconButton, List, ListItem, ListItemAvatar, ListItemText, Stack, TextField,
10
+ useTheme
11
+ } from "@mui/material";
12
+ import Button from "@mui/material/Button";
13
+ import Dialog from "@mui/material/Dialog";
14
+ import DialogActions from "@mui/material/DialogActions";
15
+ import DialogContent from "@mui/material/DialogContent";
16
+ import DialogTitle from "@mui/material/DialogTitle";
17
+
18
+ import {formatTime, timeStringToNumber, unformatTime} from "../../../common/Helpers";
19
+ import Styles from "./CutModal.styl";
20
+
21
+ export type TrackCut = {
22
+ id: string;
23
+ title: string;
24
+ startTime: number;
25
+ endTime: number;
26
+ };
27
+
28
+ export type CutModalProps = {
29
+ id: string;
30
+ duration: number;
31
+ open?: boolean;
32
+ onClose?: (data: TrackCut[]) => void;
33
+ onCancel?: () => void;
34
+ };
35
+
36
+ export const CutModal: React.FC<CutModalProps> = (props: CutModalProps) => {
37
+ const {onClose, onCancel, duration, open, ...other} = props;
38
+ const {t} = useTranslation();
39
+ const theme = useTheme();
40
+ const [entries, setEntries] = useState<TrackCut[]>([
41
+ {
42
+ id: uniqueId(),
43
+ title: "",
44
+ startTime: 0,
45
+ endTime: duration,
46
+ }
47
+ ]);
48
+
49
+ const onCutStartTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
50
+ const id = event.target.dataset.id;
51
+
52
+ setEntries((prev) => map(prev, (entry) => {
53
+ if (entry.id === id) {
54
+ return {...entry, startTime: timeStringToNumber(event.target.value)};
55
+ }
56
+
57
+ return entry;
58
+ }));
59
+ };
60
+
61
+ const onCutEndTimeChange = (event: React.ChangeEvent<HTMLInputElement>) => {
62
+ const id = event.target.dataset.id;
63
+
64
+ setEntries((prev) => map(prev, (entry) => {
65
+ if (entry.id === id) {
66
+ return {...entry, endTime: timeStringToNumber(event.target.value)};
67
+ }
68
+
69
+ return entry;
70
+ }));
71
+ };
72
+
73
+ const onAddClick = () => {
74
+ setEntries((prev) => {
75
+ const lastEntry = last(prev);
76
+ const updated = [...prev, {
77
+ id: uniqueId(),
78
+ title: "",
79
+ startTime: lastEntry ? lastEntry.endTime + 1 : 0,
80
+ endTime: duration,
81
+ }];
82
+
83
+ return updated;
84
+ });
85
+ };
86
+
87
+ const onTitleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
88
+ const id = event.target.dataset.id;
89
+
90
+ setEntries((prev) => map(prev, (entry) => {
91
+ if (entry.id === id) {
92
+ return {...entry, title: event.target.value};
93
+ }
94
+
95
+ return entry;
96
+ }));
97
+ };
98
+
99
+ const onDeleteClick = (event: React.MouseEvent<HTMLButtonElement>) => {
100
+ const id = event.currentTarget.dataset.id;
101
+
102
+ setEntries((prev) => filter(prev, (entry) => entry.id !== id));
103
+ };
104
+
105
+ const handleClose = () => {
106
+ if (onClose) {
107
+ onClose(entries);
108
+ }
109
+ };
110
+
111
+ const handleCancel = () => {
112
+ if (onCancel) {
113
+ onCancel();
114
+ }
115
+ };
116
+
117
+ const handleKeyUp = (e: KeyboardEvent) => {
118
+ if (e.key === "Enter") {
119
+ onClose(entries);
120
+ }
121
+ };
122
+
123
+ return (
124
+ <Dialog
125
+ open={open}
126
+ disablePortal
127
+ onClose={handleCancel}
128
+ fullWidth
129
+ maxWidth="md"
130
+ className={Styles.cutModal}
131
+ onKeyUp={handleKeyUp}
132
+ {...other}
133
+ >
134
+ <DialogTitle textAlign="center">{t("cutModalTitle")}</DialogTitle>
135
+ <DialogContent dividers className={Styles.content}>
136
+ <Grid container display="flex" justifyItems="flex-end">
137
+ <Grid size={12}>
138
+ <List>
139
+ {map(entries, (entry, index) => <ListItem sx={{paddingX: 0}} divider key={entry.id}>
140
+ <ListItemAvatar><Avatar variant="circular" sx={{bgcolor: theme.palette.text.secondary}}>{index + 1}</Avatar></ListItemAvatar>
141
+ <ListItemText disableTypography>
142
+ <Stack direction="row" spacing={2} className={Styles.entryContent}>
143
+ <NumberFormatBase
144
+ slotProps={{
145
+ htmlInput: {"data-id": entry.id}
146
+ }}
147
+ value={entry.startTime}
148
+ onChange={onCutStartTimeChange}
149
+ format={formatTime}
150
+ removeFormatting={unformatTime}
151
+ customInput={TextField}
152
+ variant="outlined"
153
+ label={t("from")}
154
+ />
155
+ <NumberFormatBase
156
+ slotProps={{
157
+ htmlInput: {"data-id": entry.id}
158
+ }}
159
+ value={entry.endTime}
160
+ onChange={onCutEndTimeChange}
161
+ format={formatTime}
162
+ removeFormatting={unformatTime}
163
+ customInput={TextField}
164
+ variant="outlined"
165
+ label={t("to")}
166
+ />
167
+ <TextField
168
+ data-id={entry.id}
169
+ value={entry.title}
170
+ fullWidth
171
+ variant="outlined"
172
+ label={t("title")}
173
+ onChange={onTitleChange}
174
+ slotProps={{
175
+ htmlInput: {"data-id": entry.id}
176
+ }}
177
+ />
178
+ <IconButton className={Styles.deleteButton} data-id={entry.id} color="inherit" onClick={onDeleteClick}>
179
+ <DeleteForeverIcon />
180
+ </IconButton>
181
+ </Stack>
182
+ </ListItemText>
183
+ </ListItem>)}
184
+ </List>
185
+ </Grid>
186
+ <Grid size={12} display="flex" justifyContent="center" alignItems="stretch">
187
+ <Button startIcon={<AddIcon />} className={Styles.addButton} variant="contained" disableElevation color="secondary" onClick={onAddClick}>{t("add")}</Button>
188
+ </Grid>
189
+ </Grid>
190
+ </DialogContent>
191
+ <DialogActions sx={{justifyContent: "end"}}>
192
+ <Button disabled={some(entries, (entry) => isEmpty(entry.title))} variant="contained" disableElevation autoFocus color="secondary" onClick={handleClose}>
193
+ {t("ok")}
194
+ </Button>
195
+ </DialogActions>
196
+ </Dialog>
197
+ );
198
+ };
199
+
200
+ export default CutModal;
@@ -155,6 +155,7 @@ export const NumberField = (props: INumberFieldProps) => {
155
155
  decimalScale={decimalScale}
156
156
  fixedDecimalScale={fixedDecimalScale}
157
157
  isAllowed={isAllowed}
158
+ label={label}
158
159
  {...rest}
159
160
  />
160
161
  );
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  compact, every, filter, isEmpty, isFunction, map, replace, truncate, uniq, without
3
3
  } from "lodash-es";
4
- import React, {ChangeEvent, useCallback, useEffect, useMemo, useRef, useState} from "react";
4
+ import React, {
5
+ ChangeEvent, KeyboardEvent, useCallback, useEffect, useMemo, useRef, useState
6
+ } from "react";
5
7
  import {useTranslation} from "react-i18next";
6
8
  import {useDebounceValue} from "usehooks-ts";
7
9
 
@@ -132,6 +134,12 @@ export const InputPanel: React.FC<InputPanelProps> = (props: InputPanelProps) =>
132
134
  event.target.value = "";
133
135
  };
134
136
 
137
+ const onTextFieldKeyUp = (event: KeyboardEvent<HTMLInputElement>) => {
138
+ if (event.key === "Enter" && event.ctrlKey) {
139
+ handleLoadInfo();
140
+ }
141
+ };
142
+
135
143
  const onShowAdvancedSearchOptionsChange = (e: React.SyntheticEvent<HTMLDivElement>, isExpanded: boolean) => {
136
144
  setApplicationOptions((prev) => ({...prev, showAdvancedSearchOptions: isExpanded}));
137
145
  };
@@ -229,6 +237,7 @@ export const InputPanel: React.FC<InputPanelProps> = (props: InputPanelProps) =>
229
237
  variant="outlined"
230
238
  label={getInputLabel()}
231
239
  error={containsInvalidValues}
240
+ onKeyUp={onTextFieldKeyUp}
232
241
  slotProps={{
233
242
  input: {
234
243
  ...params.InputProps,
@@ -65,6 +65,11 @@
65
65
  padding: 12px;
66
66
  }
67
67
 
68
+ .cut-track {
69
+ min-width: 12px;
70
+ padding: 12px;
71
+ }
72
+
68
73
  .remove {
69
74
  min-width: 12px;
70
75
  padding: 12px;
@@ -1,6 +1,6 @@
1
1
  import classnames from "classnames";
2
2
  import {ipcRenderer} from "electron";
3
- import {assign, includes, isFunction, pick, some} from "lodash-es";
3
+ import {assign, cloneDeep, forEach, includes, isFunction, last, map, pick, some} from "lodash-es";
4
4
  import moment from "moment";
5
5
  import React, {useCallback, useState} from "react";
6
6
  import {useTranslation} from "react-i18next";
@@ -9,15 +9,17 @@ import AspectRatioIcon from "@mui/icons-material/AspectRatio";
9
9
  import CloseIcon from "@mui/icons-material/Close";
10
10
  import DownloadIcon from "@mui/icons-material/Download";
11
11
  import EditIcon from "@mui/icons-material/Edit";
12
+ import FormatListNumberedIcon from "@mui/icons-material/FormatListNumbered";
12
13
  import LaunchIcon from "@mui/icons-material/Launch";
13
14
  import YouTubeIcon from "@mui/icons-material/YouTube";
14
15
  import {
15
16
  Box, Button, Card, CardContent, CardMedia, Grid, LinearProgress, Tooltip, Typography
16
17
  } from "@mui/material";
17
18
 
18
- import {AlbumInfo} from "../../../common/Youtube";
19
+ import {AlbumInfo, PlaylistInfo} from "../../../common/Youtube";
19
20
  import {Messages} from "../../../messaging/Messages";
20
21
  import {useDataState} from "../../../react/contexts/DataContext";
22
+ import CutModal, {TrackCut} from "../../modals/cutModal/CutModal";
21
23
  import DetailsModal from "../../modals/detailsModal/DetailsModal";
22
24
  import ImageModal from "../../modals/imageModal/ImageModal";
23
25
  import Progress from "../../progress/Progress";
@@ -25,6 +27,7 @@ import Styles from "./MediaInfoPanel.styl";
25
27
 
26
28
  export type MediaInfoPanelProps = {
27
29
  item?: AlbumInfo;
30
+ playlist?: PlaylistInfo;
28
31
  className?: string;
29
32
  loading?: boolean;
30
33
  progress?: number;
@@ -34,12 +37,14 @@ export type MediaInfoPanelProps = {
34
37
  }
35
38
 
36
39
  export const MediaInfoPanel: React.FC<MediaInfoPanelProps> = (props: MediaInfoPanelProps) => {
37
- const {item, className, onCancel, onDownload, onOpenOutput, loading, progress = 0} = props;
38
- const {trackStatus, queue} = useDataState();
40
+ const {item, playlist, className, onCancel, onDownload, onOpenOutput, loading, progress = 0} = props;
41
+ const {trackStatus, setPlaylists, setTrackCuts, queue} = useDataState();
39
42
  const [detailsModalOpen, setDetailsModalOpen] = useState(false);
43
+ const [cutTrackModalOpen, setCutTrackModalOpen] = useState(false);
40
44
  const [imageModalOpen, setImageModalOpen] = useState(false);
41
45
  const {t} = useTranslation();
42
46
  const [value, setValue] = useState(item);
47
+ const [tracksSeparated, setTracksSeparated] = useState<boolean>();
43
48
 
44
49
  const onDetailsModalClose = (data: AlbumInfo) => {
45
50
  setValue((prev) => assign(prev, data));
@@ -49,6 +54,52 @@ export const MediaInfoPanel: React.FC<MediaInfoPanelProps> = (props: MediaInfoPa
49
54
  const onImageModalClose = () => {
50
55
  setImageModalOpen(false);
51
56
  };
57
+
58
+ const onCutTrackModalCancel = () => {
59
+ setCutTrackModalOpen(false);
60
+ };
61
+
62
+ const onCutTrackModalClose = (cuts: TrackCut[]) => {
63
+ setPlaylists((prev) => {
64
+ const found = prev.find((p) => p.url === playlist.url);
65
+ const currentTrack = last(found.tracks);
66
+
67
+ return map(prev, (p) => {
68
+ if (p.url === playlist.url) {
69
+ const newTracks = map(cuts, (cutTrack, index) => {
70
+ const clonedTrack = cloneDeep(currentTrack);
71
+
72
+ return assign({}, clonedTrack, {
73
+ id: cutTrack.id,
74
+ title: cutTrack.title,
75
+ playlist: p.url,
76
+ playlist_autonumber: index + 1,
77
+ playlist_count: cuts.length,
78
+ });
79
+ });
80
+ const updated = assign({}, found, {album: assign({}, found.album, {tracksNumber: cuts.length}), tracks: newTracks});
81
+
82
+ return updated;
83
+ }
84
+
85
+ return p;
86
+ });
87
+ });
88
+
89
+ setTrackCuts((prev) => {
90
+ const newCuts: {[key: string]: [number, number][]} = {};
91
+
92
+ forEach(cuts, (c) => {
93
+ newCuts[c.id] = [[c.startTime, c.endTime]];
94
+
95
+ });
96
+
97
+ return {...prev, ...newCuts};
98
+ });
99
+
100
+ setTracksSeparated(true);
101
+ setCutTrackModalOpen(false);
102
+ };
52
103
 
53
104
  const cancel = () => {
54
105
  if (isFunction(onCancel)) {
@@ -68,7 +119,11 @@ export const MediaInfoPanel: React.FC<MediaInfoPanelProps> = (props: MediaInfoPa
68
119
 
69
120
  const editInfo = useCallback(() => {
70
121
  setDetailsModalOpen(true);
71
- }, [detailsModalOpen, setDetailsModalOpen]);
122
+ }, [cutTrackModalOpen, setCutTrackModalOpen]);
123
+
124
+ const cutTrack = useCallback(() => {
125
+ setCutTrackModalOpen(true);
126
+ }, [cutTrackModalOpen, setCutTrackModalOpen]);
72
127
 
73
128
  const showCoverImage = useCallback(() => {
74
129
  setImageModalOpen(true);
@@ -121,6 +176,11 @@ export const MediaInfoPanel: React.FC<MediaInfoPanelProps> = (props: MediaInfoPa
121
176
  <EditIcon />
122
177
  </Button>
123
178
  </Tooltip>
179
+ {(tracksSeparated || playlist.tracks.length === 1) && <Tooltip title={t("cut")} arrow enterDelay={2000} leaveDelay={100} enterNextDelay={500} placement="top">
180
+ <Button data-help="cutTrack" className={Styles.cutTrack} size="large" fullWidth variant="contained" color="primary" disableElevation onClick={cutTrack}>
181
+ <FormatListNumberedIcon />
182
+ </Button>
183
+ </Tooltip>}
124
184
  {some(trackStatus, (s) => s.completed) &&
125
185
  <Tooltip title={t("openOutputDirectory")} arrow enterDelay={2000} leaveDelay={100} enterNextDelay={500} placement="top">
126
186
  <Button data-help="openOutputDirectory" className={Styles.openOutput} size="large" fullWidth variant="contained" color="primary" disableElevation onClick={openOutputFolder}>
@@ -175,6 +235,13 @@ export const MediaInfoPanel: React.FC<MediaInfoPanelProps> = (props: MediaInfoPa
175
235
  onClose={onImageModalClose}
176
236
  title={value.title}
177
237
  />
238
+ <CutModal
239
+ id="cut-modal"
240
+ duration={value.duration}
241
+ open={cutTrackModalOpen}
242
+ onClose={onCutTrackModalClose}
243
+ onCancel={onCutTrackModalCancel}
244
+ />
178
245
  </>
179
246
  );
180
247
  };
@@ -220,6 +220,7 @@ export const PlaylistTabs: React.FC<PlaylistTabsProps> = (props: PlaylistTabsPro
220
220
  {map(filter(playlists, (p) => !isEmpty(p.album)), (item) =>
221
221
  <TabPanel className={Styles.tabPanel} value={item.url} key={item.url}>
222
222
  <MediaInfoPanel
223
+ playlist={item}
223
224
  item={item.album}
224
225
  loading={isPlaylistLoading(item.album.id)}
225
226
  progress={getTotalProgress(item.album.id)}
@@ -21,7 +21,9 @@ import {
21
21
  Tooltip, Typography
22
22
  } from "@mui/material";
23
23
 
24
- import {formatFileSize} from "../../../common/Helpers";
24
+ import {
25
+ formatFileSize, formatTime, timeStringToNumber, unformatTime
26
+ } from "../../../common/Helpers";
25
27
  import {TrackInfo, TrackStatusInfo} from "../../../common/Youtube";
26
28
  import {useDataState} from "../../../react/contexts/DataContext";
27
29
  import DetailsModal from "../../modals/detailsModal/DetailsModal";
@@ -50,28 +52,6 @@ export const TrackList: React.FC<TrackListProps> = (props: TrackListProps) => {
50
52
  setValue(items ?? tracks);
51
53
  }, [items, tracks]);
52
54
 
53
- const formatTime = (value: string) => {
54
- const formatted = moment.duration(value, "seconds").format("HH:mm:ss", {trim: "left"});
55
-
56
- if (/^\d{2}$/.test(formatted)) {
57
- return `00:${formatted}`;
58
- }
59
-
60
- return formatted;
61
- };
62
-
63
- const unformatTime = (value: string) => {
64
- if (/^\d{2}$/.test(value)) {
65
- return moment.duration(`00:00:${value}`).asSeconds() + "";
66
- }
67
-
68
- return moment.duration(`00:${value}`).asSeconds() + "";
69
- };
70
-
71
- const timeStringToNumber = (value: string) => {
72
- return moment.duration(`00:${value}`).asSeconds();
73
- };
74
-
75
55
  const sanitizeTrackCuts = (source: {[key: string]: [number, number][]}) => {
76
56
  forEach(source, (v, k) => {
77
57
  const track = find(value, ["id", k]);
@@ -1,4 +1,5 @@
1
1
  {
2
+ "add": "Hinzufügen",
2
3
  "albumOrAlbums": "Albumname/Albumnamen",
3
4
  "albumOutputTemplate": "Ausgabenvorlage (Alben)",
4
5
  "albums": "Alben",
@@ -18,6 +19,7 @@
18
19
  "convertingOutput": "Ausgabedatei konvertieren",
19
20
  "customYtdlpArgs": "Benutzerdefinierte Argumente für yt-dlp",
20
21
  "cut": "Schneiden",
22
+ "cutModalTitle": "Track Extraktion",
21
23
  "debugMode": "Debug-Modus",
22
24
  "default": "Default",
23
25
  "detailsModalTitle": "Mediendetails bearbeiten",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "add": "Add",
2
3
  "albumOrAlbums": "Album/albums",
3
4
  "albumOutputTemplate": "Output template (albums)",
4
5
  "albums": "Albums",
@@ -18,6 +19,7 @@
18
19
  "convertingOutput": "Converting output",
19
20
  "customYtdlpArgs": "Custom arguments for yt-dlp",
20
21
  "cut": "Cut",
22
+ "cutModalTitle": "Track Extraction",
21
23
  "debugMode": "Debug Mode",
22
24
  "default": "Default",
23
25
  "detailsModalTitle": "Edit media details",
@@ -1,4 +1,5 @@
1
1
  {
2
+ "add": "Dodaj",
2
3
  "albumOrAlbums": "Nazwa lub nazwy albumów",
3
4
  "albumOutputTemplate": "Szablon wyjściowy (albumy)",
4
5
  "albums": "Albumy",
@@ -18,6 +19,7 @@
18
19
  "convertingOutput": "Konwertowanie pliku",
19
20
  "customYtdlpArgs": "Dodatkowe argumenty dla yt-dlp",
20
21
  "cut": "Przytnij",
22
+ "cutModalTitle": "Wyodrębnianie ścieżek",
21
23
  "debugMode": "Tryb debugowania",
22
24
  "default": "Domyślna",
23
25
  "detailsModalTitle": "Edycja danych",