diff --git a/.vscode/settings.json b/.vscode/settings.json
index c80eb119..8b85e87e 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -49,6 +49,7 @@
"daterange",
"defusedxml",
"delenv",
+ "denoland",
"dlfields",
"dotdot",
"dotenv",
@@ -100,6 +101,7 @@
"mccabe",
"mebibytes",
"MEIPASS",
+ "merch",
"Microformat",
"microformats",
"mkvtoolsnix",
@@ -153,6 +155,7 @@
"threadsafe",
"tiktok",
"timespec",
+ "tinyurl",
"tmpfilename",
"toplevel",
"ungroup",
diff --git a/Dockerfile b/Dockerfile
index 8ca4bd7d..0132a114 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -66,12 +66,13 @@ COPY --chown=app:app --from=python_builder /opt/python /opt/python
COPY --from=ghcr.io/arabcoders/alpine-mp4box /usr/bin/mp4box /usr/bin/mp4box
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffmpeg /usr/bin/ffmpeg
COPY --from=ghcr.io/arabcoders/jellyfin-ffmpeg /usr/bin/ffprobe /usr/bin/ffprobe
+COPY --from=denoland/deno:latest /usr/bin/deno /usr/bin/deno
COPY --chown=app:app ./healthcheck.sh /usr/local/bin/healthcheck
ENV PATH="/opt/python/bin:$PATH"
RUN chown -R app:app /config /downloads && \
- chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box /usr/bin/ffmpeg /usr/bin/ffprobe
+ chmod +x /usr/local/bin/healthcheck /usr/bin/mp4box /usr/bin/ffmpeg /usr/bin/ffprobe /usr/bin/deno
VOLUME /config
VOLUME /downloads
diff --git a/app/library/Presets.py b/app/library/Presets.py
index aed370f1..22477f74 100644
--- a/app/library/Presets.py
+++ b/app/library/Presets.py
@@ -54,16 +54,24 @@ DEFAULT_PRESETS: list[dict[int, dict[str, str | bool]]] = [
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f60",
"name": "Info Reader Plugin",
"description": 'This preset generate specific filename format and metadata to work with yt-dlp info reader plugins for jellyfin/emby and plex and to make play state sync work for WatchState.\n\nThere is one more step you need to do via Other > Terminal if you have it enabled or directly from container shell\n\nyt-dlp -I0 --write-info-json --write-thumbnail --convert-thumbnails jpg --paths /downloads/youtube -o "%(channel|Unknown_title)s [%(channel_id|Unknown_id)s]/%(title).180B [%(channel_id|Unknown_id)s].%(ext)s" -- https://www.youtube.com/channel/UCClfFsWcT3N2I7VTXXyt84A\n\nChange the url to the channel you want to download.\n\nFor more information please visit \nhttps://github.com/arabcoders/watchstate/blob/master/FAQ.md#how-to-get-watchstate-working-with-youtube-contentlibrary',
- "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(extractor)s-%(id)s].%(ext)s",
+ "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/%(release_date>%Y%m%d,upload_date>%Y%m%d)s - %(title).180B [%(id)s].%(ext)s",
"cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--windows-filenames --write-info-json --embed-info-json \n--convert-thumbnails jpg --write-thumbnail --embed-metadata",
"default": True,
},
{
"id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f61",
"name": "NFO Maker TV",
- "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex.",
- "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(extractor)s-%(id)s].%(ext)s",
- "cli": "--socket-timeout 30 --download-archive %(config_path)s/archive.log \n--windows-filenames --convert-thumbnails jpg --write-thumbnail \n--use-postprocessor NFOMakerTvPP",
+ "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for episodes.",
+ "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
+ "cli": '--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--use-postprocessor "NFOMakerPP:when=after_move" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
+ "default": True,
+ },
+ {
+ "id": "2ade2c28-cad4-4a06-b7eb-2439fdf46f61",
+ "name": "NFO Maker Movie",
+ "description": "This preset generate specific filename format and metadata to work with media servers like jellyfin/emby and plex for movies.",
+ "template": "%(channel)s [%(channel_id|Unknown_id)s]/Season %(release_date>%Y,upload_date>%Y|Unknown)s/S%(release_date>%Y,upload_date>%Y)sE%(release_date>%m%d,upload_date>%m%d)s - %(title).100B [%(id)s].%(ext)s",
+ "cli": '--socket-timeout 30 --download-archive %(config_path)s/archive.log\n--use-postprocessor "NFOMakerPP:when=after_move;mode=movie" \n--windows-filenames --convert-thumbnails jpg --write-thumbnail',
"default": True,
},
]
diff --git a/app/library/task_handlers/youtube.py b/app/library/task_handlers/youtube.py
index 63cba520..4db7be5c 100644
--- a/app/library/task_handlers/youtube.py
+++ b/app/library/task_handlers/youtube.py
@@ -129,10 +129,10 @@ class YoutubeHandler(BaseHandler):
None if the URL is neither.
"""
- if m := YoutubeHandler.CHANNEL_REGEX.match(url):
+ if (m := YoutubeHandler.CHANNEL_REGEX.match(url)) and m.group("id"):
return {"type": "channel_id", "id": m.group("id")}
- if m := YoutubeHandler.PLAYLIST_REGEX.match(url):
+ if (m := YoutubeHandler.PLAYLIST_REGEX.match(url)) and m.group("id"):
return {"type": "playlist_id", "id": m.group("id")}
return None
diff --git a/app/postprocessors/__init__.py b/app/postprocessors/__init__.py
index 727ca673..80c2e23d 100644
--- a/app/postprocessors/__init__.py
+++ b/app/postprocessors/__init__.py
@@ -1,7 +1,7 @@
# flake8: noqa: F401
from yt_dlp.globals import postprocessors
-from .make_nfo import NFOMakerMoviePP, NFOMakerTvPP
+from .nfo_maker import NFOMakerPP
_ytptube_pps = {name: value for name, value in globals().items() if name.endswith("PP")}
diff --git a/app/postprocessors/make_nfo.py b/app/postprocessors/nfo_maker.py
similarity index 83%
rename from app/postprocessors/make_nfo.py
rename to app/postprocessors/nfo_maker.py
index 377aa681..70ab7ff5 100644
--- a/app/postprocessors/make_nfo.py
+++ b/app/postprocessors/nfo_maker.py
@@ -13,7 +13,7 @@ if TYPE_CHECKING:
from collections.abc import Iterable
-class NFOMaker(PostProcessor):
+class NFOMakerPP(PostProcessor):
"""
A Post-Processor that writes metadata to an NFO file using a simple
placeholder-based template engine.
@@ -24,8 +24,6 @@ class NFOMaker(PostProcessor):
- ("date_field", "%Y") -> year from date_field
"""
- _MAPPING: dict[str, Any] = {}
- _TEMPLATE: str | None = None
_DATE_FIELDS: tuple[str, ...] = ("upload_date", "release_date", "aired", "premiered")
_URL_PAT = re.compile(
@@ -43,6 +41,79 @@ class NFOMaker(PostProcessor):
r"snapchat|reddit|telegram|t\.me|whatsapp|link\s+in\s+bio|bit\.ly|tinyurl|goo\.gl"
r")\b"
)
+ _args: dict[str, Any] = {}
+ _MODE: dict = {
+ "tv": {
+ "mapping": {
+ "title": "title",
+ "season": ("season_number", "season", "year", ("release_date", "%Y"), ("upload_date", "%Y")),
+ "episode": (
+ "episode_number",
+ "episode",
+ ("release_date", "%m%d"),
+ ("upload_date", "%m%d"),
+ "unique_id",
+ ),
+ "aired": ("release_date", "upload_date"),
+ "author": "uploader",
+ "plot": "description",
+ "id": "id",
+ "extractor": "extractor",
+ },
+ "template": """
+
+ {title}
+ {season}
+ {episode}
+ {aired}
+ {unique_id}
+ {id}
+ {plot}
+
+""".strip("\n"),
+ },
+ "movie": {
+ "mapping": {
+ "title": "title",
+ "plot": "description",
+ "runtime": "duration",
+ "thumb": "thumbnail",
+ "id": "id",
+ "country": "language",
+ "aired": ("release_date", "upload_date"),
+ "extractor": "extractor",
+ "year": ("release_year", "year"),
+ "author": "uploader",
+ "trailer": "webpage_url",
+ },
+ "template": """
+
+ {title}
+ {plot}
+ {runtime}
+ {thumb}
+ {id}
+ {unique_id}
+ {id}
+ {country}
+ {aired}
+ {year}
+ {trailer}
+
+""".strip("\n"),
+ },
+ }
+
+ mode: str = "tv"
+ "The NFO mode to use. Either 'tv' or 'movie'. Default is 'tv'."
+
+ prefix: bool = True
+ "Prefix episodes with 1 for better sorting."
+
+ def __init__(self, downloader, mode: str = "tv", prefix: bool = True) -> None:
+ PostProcessor.__init__(self, downloader)
+ self.mode = str(mode).lower()
+ self.prefix = prefix in (True, "true", "1", 1)
@classmethod
def pp_key(cls) -> str:
@@ -53,8 +124,8 @@ class NFOMaker(PostProcessor):
self.to_screen("No info provided to NFO Maker.")
return [], {}
- if not self._TEMPLATE:
- self.to_screen("NFO template not set, skipping NFO creation.")
+ if self.mode not in self._MODE:
+ self.to_screen(f"Invalid mode '{self.mode}'.")
return [], info
# prefer explicit final path if present, else fall back to filename
@@ -103,7 +174,7 @@ class NFOMaker(PostProcessor):
def _collect_nfo_data(self, info: dict) -> dict[str, Any]:
data: dict[str, Any] = {}
- for nfo_name, spec in self._MAPPING.items():
+ for nfo_name, spec in self._MODE[self.mode].get("mapping", {}).items():
try:
values = spec if isinstance(spec, tuple) else (spec,)
resolved_key = None
@@ -166,7 +237,7 @@ class NFOMaker(PostProcessor):
self.to_screen("Invalid aired date parts, skipping NFO creation.")
return False
- self.to_screen(f"Creating NFO file at {nfo_file!s}")
+ self.to_screen(f"Creating {self.mode} NFO file at {nfo_file!s}")
nfo_file.parent.mkdir(parents=True, exist_ok=True)
nfo_file.touch(exist_ok=True)
@@ -174,7 +245,7 @@ class NFOMaker(PostProcessor):
data = dict(data) # do not mutate original
data["unique_id"] = self._build_unique_id(dt, real_file)
- self._write(nfo_file=nfo_file, text=self._TEMPLATE or "", repl=data)
+ self._write(nfo_file=nfo_file, text=self._MODE[self.mode].get("template", ""), repl=data)
return True
@staticmethod
@@ -230,7 +301,7 @@ class NFOMaker(PostProcessor):
return raw
if fmt and isinstance(fmt, str):
- date_s = NFOMaker._normalize_date(raw)
+ date_s = NFOMakerPP._normalize_date(raw)
if date_s:
try:
dt = datetime.now(tz=UTC).strptime(date_s, "%Y-%m-%d")
@@ -265,10 +336,18 @@ class NFOMaker(PostProcessor):
for key, value in safe_repl.items():
if value is None:
continue
+
+ if self.prefix and key in ("episode",):
+ try:
+ value = f"1{value}"
+ except Exception:
+ pass
+
rendered = rendered.replace(f"{{{key}}}", str(value))
# remove any unresolved placeholder lines
- unresolved_keys: Iterable[str] = set({*self._MAPPING.keys(), *safe_repl.keys()})
+ mapping = self._MODE[self.mode].get("mapping", {})
+ unresolved_keys: Iterable[str] = set({*mapping, *safe_repl.keys()})
pattern = re.compile(rf".*{{(?:{'|'.join(map(re.escape, unresolved_keys))})}}.*")
rendered = "\n".join(line for line in rendered.splitlines() if not pattern.fullmatch(line))
@@ -336,58 +415,3 @@ class NFOMaker(PostProcessor):
summary = cut[: dot + 1] if 0 < dot else cut.rstrip()
return summary
-
-
-class NFOMakerTvPP(NFOMaker):
- _MAPPING = {
- "title": "title",
- "season": ("season_number", "season", "year", ("release_date", "%Y"), ("upload_date", "%Y")),
- "episode": ("episode_number", "episode", ("release_date", "%m%d"), ("upload_date", "%m%d"), "unique_id"),
- "aired": ("release_date", "upload_date"),
- "author": "uploader",
- "plot": "description",
- "id": "id",
- "extractor": "extractor",
- }
- _TEMPLATE = """
-
- {title}
- {season}
- {episode}
- {aired}
- {unique_id}
- {id}
- {plot}
-
-""".strip("\n")
-
-
-class NFOMakerMoviePP(NFOMaker):
- _MAPPING = {
- "title": "title",
- "plot": "description",
- "runtime": "duration",
- "thumb": "thumbnail",
- "id": "id",
- "country": "language",
- "aired": ("release_date", "upload_date"),
- "extractor": "extractor",
- "year": ("release_year", "year"),
- "author": "uploader",
- "trailer": "webpage_url",
- }
- _TEMPLATE = """
-
- {title}
- {plot}
- {runtime}
- {thumb}
- {id}
- {unique_id}
- {id}
- {country}
- {aired}
- {year}
- {trailer}
-
-""".strip("\n")
diff --git a/ui/app/components/History.vue b/ui/app/components/History.vue
index 8bf45cf2..fa564054 100644
--- a/ui/app/components/History.vue
+++ b/ui/app/components/History.vue
@@ -764,7 +764,7 @@ const removeItem = async (item: StoreItem) => {
}
}
-const retryItem = (item: StoreItem, re_add = false) => {
+const retryItem = (item: StoreItem, re_add: boolean = false, remove_file: boolean = false) => {
const item_req: Partial = {
url: item.url,
preset: item.preset,
@@ -776,7 +776,7 @@ const retryItem = (item: StoreItem, re_add = false) => {
auto_start: item.auto_start,
}
- socket.emit('item_delete', { id: item._id, remove_file: false })
+ socket.emit('item_delete', { id: item._id, remove_file: remove_file })
if (selectedElms.value.includes(item._id || '')) {
selectedElms.value = selectedElms.value.filter(i => i !== item._id)
@@ -860,14 +860,18 @@ const removeFromArchiveDialog = (item: StoreItem) => {
dialog_confirm.value.visible = true
dialog_confirm.value.title = 'Remove from Archive'
dialog_confirm.value.message = `Remove '${item.title || item.id || item.url || '??'}' from archive?`
- dialog_confirm.value.options = [
- { key: 'remove_history', label: 'Also, Remove from history.' },
+ const opts = [
+ { key: 'remove_history', label: 'Remove from history.' },
{ key: 're_add', label: 'Re-add to download form.' },
- ]
+ ];
+ if (config.app.remove_files) {
+ opts.push({ key: 'dont_remove_file', label: "Don't remove associated files." })
+ }
+ dialog_confirm.value.options = opts
dialog_confirm.value.confirm = (opts: any) => removeFromArchive(item, opts)
}
-const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean }) => {
+const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, remove_history?: boolean, dont_remove_file?: boolean }) => {
try {
const req = await request(`/api/history/${item._id}/archive`, { method: 'DELETE' })
const data = await req.json()
@@ -883,13 +887,18 @@ const removeFromArchive = async (item: StoreItem, opts?: { re_add?: boolean, rem
dialog_confirm.value.visible = false
}
+ let file_delete = config.app.remove_files
+ if (opts?.dont_remove_file) {
+ file_delete = false
+ }
+
if (opts?.re_add) {
- retryItem(item, true)
+ retryItem(item, true, file_delete)
return
}
if (opts?.remove_history) {
- socket.emit('item_delete', { id: item._id, remove_file: false })
+ socket.emit('item_delete', { id: item._id, remove_file: file_delete })
}
}