Trimming video file name to avoid error when file name is too long.

This commit is contained in:
Roland 2024-07-14 09:54:02 -06:00
parent 37ff1ab2cd
commit cdc73d9d16
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
13 changed files with 140 additions and 27 deletions

View file

@ -65,6 +65,7 @@ def get_domain_directory(url: str) -> pathlib.Path:
return directory return directory
# File names include domain and datetime.
MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH = 200 MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH = 200

View file

@ -359,7 +359,7 @@ async def test_get_title_from_html(test_directory, test_session, fake_now):
@skip_circleci @skip_circleci
def test_get_new_archive_files_length(fake_now): def test_get_new_archive_files_length(test_directory, fake_now):
"""Archive titles are truncated to fit file system length limit. (255-character limit for most file systems)""" """Archive titles are truncated to fit file system length limit. (255-character limit for most file systems)"""
fake_now(datetime(2001, 1, 1)) fake_now(datetime(2001, 1, 1))
archive_files = get_new_archive_files('https://example.com', 'a' * 500) archive_files = get_new_archive_files('https://example.com', 'a' * 500)
@ -372,7 +372,7 @@ def test_get_new_archive_files_length(fake_now):
@skip_circleci @skip_circleci
def test_get_new_archive_files(fake_now): def test_get_new_archive_files(fake_now):
"""Archive files have a specific format so they are sorted by datetime, and are near each other.""" """Archive files have a specific format, so they are sorted by datetime, and are near each other."""
fake_now(datetime(2001, 1, 1)) fake_now(datetime(2001, 1, 1))
archive_files = get_new_archive_files('https://example.com/two', None) archive_files = get_new_archive_files('https://example.com/two', None)
assert str(archive_files.singlefile).endswith('archive/example.com/2001-01-01-00-00-00_NA.html') assert str(archive_files.singlefile).endswith('archive/example.com/2001-01-01-00-00-00_NA.html')

View file

@ -1,5 +1,6 @@
#! /usr/bin/env python3 #! /usr/bin/env python3
import json import json
import os.path
import pathlib import pathlib
import traceback import traceback
from abc import ABC from abc import ABC
@ -12,7 +13,7 @@ from yt_dlp.extractor import YoutubeTabIE # noqa
from wrolpi.cmd import YT_DLP_BIN from wrolpi.cmd import YT_DLP_BIN
from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators, background_task, \ from wrolpi.common import logger, get_media_directory, escape_file_name, resolve_generators, background_task, \
format_json_file format_json_file, trim_file_name
from wrolpi.dates import now from wrolpi.dates import now
from wrolpi.db import get_db_session from wrolpi.db import get_db_session
from wrolpi.db import optional_session from wrolpi.db import optional_session
@ -62,7 +63,9 @@ def extract_info(url: str, ydl: YoutubeDL = YDL, process=False) -> dict:
def prepare_filename(entry: dict, ydl: YoutubeDL = YDL) -> str: def prepare_filename(entry: dict, ydl: YoutubeDL = YDL) -> str:
"""Get filename from YoutubeDL. Separated for testing.""" """Get filename from YoutubeDL. Separated for testing."""
return ydl.prepare_filename(entry) dir_name, file_name = os.path.split(ydl.prepare_filename(entry))
file_name = trim_file_name(file_name)
return f'{dir_name}/{file_name}'
class ChannelDownloader(Downloader, ABC): class ChannelDownloader(Downloader, ABC):
@ -332,7 +335,6 @@ class VideoDownloader(Downloader, ABC):
return await self.download_info_json(download, video_path) return await self.download_info_json(download, video_path)
# Do the real download. # Do the real download.
file_name_format = '%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s'
cmd = ( cmd = (
str(YT_DLP_BIN), str(YT_DLP_BIN),
'-c', # Continue downloads '-c', # Continue downloads
@ -347,7 +349,7 @@ class VideoDownloader(Downloader, ABC):
'--write-info-json', '--write-info-json',
'--merge-output-format', PREFERRED_VIDEO_EXTENSION, '--merge-output-format', PREFERRED_VIDEO_EXTENSION,
'--remux-video', PREFERRED_VIDEO_EXTENSION, '--remux-video', PREFERRED_VIDEO_EXTENSION,
'-o', file_name_format, '-o', video_path,
'--no-cache-dir', '--no-cache-dir',
'--compat-options', 'no-live-chat', '--compat-options', 'no-live-chat',
# Get top 20 comments, 10 replies per parent. # Get top 20 comments, 10 replies per parent.
@ -496,7 +498,7 @@ class VideoDownloader(Downloader, ABC):
entry = extract_info(url, ydl=ydl, process=True) entry = extract_info(url, ydl=ydl, process=True)
final_filename = pathlib.Path(prepare_filename(entry, ydl=ydl)).absolute() final_filename = pathlib.Path(prepare_filename(entry, ydl=ydl)).absolute()
logger.debug(f'Downloading {url} to {out_dir}') logger.debug(f'Downloading {url} to {repr(str(final_filename))}')
return final_filename, entry return final_filename, entry
@staticmethod @staticmethod

View file

@ -10,7 +10,7 @@ from sqlalchemy import or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from yt_dlp import YoutubeDL from yt_dlp import YoutubeDL
from wrolpi import before_startup, dates from wrolpi import before_startup, dates, flags
from wrolpi.captions import extract_captions from wrolpi.captions import extract_captions
from wrolpi.common import ConfigFile, get_media_directory, register_refresh_cleanup, limit_concurrent from wrolpi.common import ConfigFile, get_media_directory, register_refresh_cleanup, limit_concurrent
from wrolpi.dates import Seconds, from_timestamp from wrolpi.dates import Seconds, from_timestamp
@ -427,14 +427,17 @@ def import_channels_config():
full_data.update(data) full_data.update(data)
channel.update(full_data) channel.update(full_data)
if not channel.source_id and channel.url: if not channel.source_id and channel.url and flags.have_internet.is_set():
# If we can download from a channel, we must have its source_id. # If we can download from a channel, we must have its source_id.
channel.source_id = get_channel_source_id(channel.url) try:
save_config = True channel.source_id = get_channel_source_id(channel.url)
if not channel.source_id: save_config = True
channel_import_logger.warning(f'Unable to fetch source_id for {channel.url}') if not channel.source_id:
else: channel_import_logger.warning(f'Unable to fetch source_id for {channel.url}')
session.commit() else:
session.commit()
except Exception as e:
logger.error(f'Failed to get Channel source id of {channel}', exc_info=e)
channel_import_logger.debug(f'Updated {repr(channel.name)}' channel_import_logger.debug(f'Updated {repr(channel.name)}'
f' url={channel.url}' f' url={channel.url}'

View file

@ -283,6 +283,9 @@ async def get_missing_videos_comments(limit: int = VIDEO_COMMENTS_FETCH_COUNT):
elif 'not a valid URL' in str(e): elif 'not a valid URL' in str(e):
logger.error(f'URL is not valid: {url=}', exc_info=e) logger.error(f'URL is not valid: {url=}', exc_info=e)
add_video_to_skip_list(url, censored=True) add_video_to_skip_list(url, censored=True)
elif ' Sign in ' in str(e):
logger.error(f'Video requires sign-in: {url=}', exc_info=e)
add_video_to_skip_list(url, censored=True)
else: else:
logger.error(f'Got error when attempting to download video comments: {url=}', exc_info=e) logger.error(f'Got error when attempting to download video comments: {url=}', exc_info=e)
continue continue

View file

@ -2,7 +2,8 @@ from http import HTTPStatus
from typing import List from typing import List
from modules.zim import lib from modules.zim import lib
from wrolpi.common import logger, aiohttp_get, get_download_info, download_file, background_task, get_html_soup from wrolpi.common import logger, aiohttp_get, get_download_info, download_file, background_task, get_html_soup, \
trim_file_name
from wrolpi.downloader import Downloader, Download, DownloadResult from wrolpi.downloader import Downloader, Download, DownloadResult
__all__ = ['KiwixCatalogDownloader', 'KiwixZimDownloader', 'kiwix_zim_downloader', 'kiwix_catalog_downloader'] __all__ = ['KiwixCatalogDownloader', 'KiwixZimDownloader', 'kiwix_zim_downloader', 'kiwix_catalog_downloader']
@ -72,7 +73,8 @@ class KiwixZimDownloader(Downloader):
zim_directory = lib.get_zim_directory() zim_directory = lib.get_zim_directory()
zim_directory.mkdir(parents=True, exist_ok=True) zim_directory.mkdir(parents=True, exist_ok=True)
output_path = zim_directory / f'{download_info.name}' name = trim_file_name(download_info.name)
output_path = zim_directory / name
if output_path.is_file(): if output_path.is_file():
output_size = output_path.stat().st_size output_size = output_path.stat().st_size

View file

@ -8,12 +8,14 @@ import argparse
import asyncio import asyncio
import difflib import difflib
import functools import functools
import os.path
import re import re
import sys
from difflib import SequenceMatcher from difflib import SequenceMatcher
from typing import List, Generator, Optional, Dict from typing import List, Generator, Optional, Dict
from modules.videos.models import Video, Channel
from modules.videos.lib import parse_video_file_name from modules.videos.lib import parse_video_file_name
from modules.videos.models import Video, Channel
from wrolpi.common import get_relative_to_media_directory, chain from wrolpi.common import get_relative_to_media_directory, chain
from wrolpi.db import get_db_curs, get_db_session from wrolpi.db import get_db_curs, get_db_session
from wrolpi.files.models import FileGroup from wrolpi.files.models import FileGroup
@ -95,6 +97,8 @@ async def rank_video_quality(video: Video, sizes_by_source_id: Dict[str, int]) -
rank += 2 rank += 2
if video_file_matches_channel_info_json(video): if video_file_matches_channel_info_json(video):
rank += 2 rank += 2
if video.get_comments():
rank += 2
# These are more common. # These are more common.
if video.channel_id: if video.channel_id:
rank += 1 rank += 1
@ -124,7 +128,7 @@ async def rank_video_quality(video: Video, sizes_by_source_id: Dict[str, int]) -
def delete_video(video_id: int): def delete_video(video_id: int):
with get_db_session(commit=True) as session: with get_db_session(commit=True) as session:
video = session.query(Video).filter_by(id=video_id).one() video: Video = session.query(Video).filter_by(id=video_id).one()
video.delete() video.delete()
@ -183,12 +187,29 @@ async def handle_user_delete_duplicates(similar_videos: List[Video], video_url:
if all_changes.isdigit(): if all_changes.isdigit():
print(f'\nSkipping {similar_videos[0]} and similar because they are parts') print(f'\nSkipping {similar_videos[0]} and similar because they are parts')
return return
if delete_lower_ranked_automatically and any(PART_MATCH.match(i.file_group.title) for i in similar_videos):
# "Part" detection is far too inaccurate, do not delete part videos.
print(f'\nSkipping {similar_videos[0]} and similar because they may be parts')
return
# Preserve the largest duplicate video. # Preserve the largest duplicate video.
sizes_by_source_id = largest_size_by_source_id(similar_videos) sizes_by_source_id = largest_size_by_source_id(similar_videos)
print('\n') print('\n')
ranked_videos = [(await rank_video_quality(i, sizes_by_source_id), i) for i in similar_videos] ranked_videos = [(await rank_video_quality(i, sizes_by_source_id), i) for i in similar_videos]
# Largest video gets a rank bump.
largest_video_idx = None
largest_video_size = None
for idx, video in enumerate([i[1] for i in ranked_videos]):
if largest_video_size and video.file_group.size > largest_video_size:
largest_video_idx = idx
largest_video_size = video.file_group.size
else:
largest_video_idx = idx
largest_video_size = video.file_group.size
ranked_videos[largest_video_idx] = \
(ranked_videos[largest_video_idx][0] + 1, ranked_videos[largest_video_idx][1])
# Order videos by rank, then size.
ranked_videos = sorted(ranked_videos, key=lambda i: (i[0], i[1].file_group.size), reverse=True) ranked_videos = sorted(ranked_videos, key=lambda i: (i[0], i[1].file_group.size), reverse=True)
for rank, video in ranked_videos: for rank, video in ranked_videos:
fg = video.file_group fg = video.file_group
@ -212,13 +233,17 @@ async def handle_user_delete_duplicates(similar_videos: List[Video], video_url:
index = similar_videos.index(lowest_video) index = similar_videos.index(lowest_video)
del similar_videos[index] del similar_videos[index]
continue continue
response = input('Delete the lowest ranked video? (y/N)').lower() response = input('Delete the lowest ranked video? Merge? (y/m/N)').lower()
if response == 'y': if response == 'y':
# Don't need to confirm when the video has been ranked low. # Don't need to confirm when the video has been ranked low.
delete_video(lowest_video.id) delete_video(lowest_video.id)
index = similar_videos.index(lowest_video) index = similar_videos.index(lowest_video)
del similar_videos[index] del similar_videos[index]
elif response == 'n': elif response == 'm':
success = await handle_merge(ranked_videos)
if success:
break
elif response == 'n' or response == '':
break break
elif response == 'c': elif response == 'c':
raise SkipChannel() raise SkipChannel()
@ -237,6 +262,47 @@ async def handle_user_delete_duplicates(similar_videos: List[Video], video_url:
raise SkipChannel() raise SkipChannel()
async def handle_merge(videos: List[tuple[int, Video]]) -> bool | None:
"""Replace the video file of Video with better data. Delete all other Videos."""
response = input(
'Keep which video file? Empty will replace largest video with best rank. (starting at 0..., n to skip)')
if response == 'n':
return
if response == '':
# Largest Video.
keep_video = sorted(videos, key=lambda i: i[1].video_path.stat().st_size, reverse=True)[0][1]
# Highest ranked video.
keep_data = videos[0][1]
else:
if not response.isdigit() or ((keep_video := int(response)) and keep_video > len(videos) - 1):
print('Invalid file index', file=sys.stderr)
return
if len(videos) == 2:
keep_data = 0 if keep_video == 1 else 1
else:
response = input('Keep which video data? (starting at 0..., n to skip)')
if response == 'n':
return
if not response.isdigit() or ((keep_data := int(response)) and keep_data > len(videos) - 1):
print('Invalid file index', file=sys.stderr)
return
keep_video = videos[keep_video][1]
keep_data = videos[keep_data][1]
if keep_video == keep_data:
print('Cannot keep and delete same video', file=sys.stderr),
return
# Replace video file of Video with better metadata.
print(f'{keep_video.video_path} -> {keep_data.video_path}')
os.replace(keep_video.video_path, keep_data.video_path)
for _, video in videos:
if video.id != keep_data.id:
delete_video(video.id)
keep_data.validate()
return True
async def main(channel_id: int = None, minimum_rank: float = MINIMUM_RANK, video_url: str = None, async def main(channel_id: int = None, minimum_rank: float = MINIMUM_RANK, video_url: str = None,
directory: str = None, delete_lower_ranked_automatically: bool = False): directory: str = None, delete_lower_ranked_automatically: bool = False):
with get_db_curs() as curs: with get_db_curs() as curs:

View file

@ -223,6 +223,7 @@ __all__ = [
'slow_logger', 'slow_logger',
'split_lines_by_length', 'split_lines_by_length',
'timer', 'timer',
'trim_file_name',
'truncate_generator_bytes', 'truncate_generator_bytes',
'truncate_object_bytes', 'truncate_object_bytes',
'tsvector', 'tsvector',
@ -994,6 +995,25 @@ def escape_file_name(name: str) -> str:
return name.strip() return name.strip()
# Maximum length is probably 255, but we need more length for large suffixes like `.readability.json`, and temporary
# downloading suffixes.
MAXIMUM_FILE_LENGTH = 180
def trim_file_name(name: str) -> str:
"""Shorten the file name only if it is longer than the file system supports. Trim from the end of the name until
the name is short enough, excluding suffix."""
if len(name) < MAXIMUM_FILE_LENGTH:
return name
# Don't trim the filename to exactly 256 characters.
# This is because a FileGroup will have varying filename lengths.
from wrolpi.files.lib import split_path_stem_and_suffix
stem, suffix = split_path_stem_and_suffix(name)
excess = MAXIMUM_FILE_LENGTH - len(suffix)
return stem[:excess].strip() + suffix
def native_only(func: callable): def native_only(func: callable):
"""Wraps a function. Raises NativeOnly if run while Dockerized.""" """Wraps a function. Raises NativeOnly if run while Dockerized."""

View file

@ -496,7 +496,7 @@ class DownloadManager:
-> List[Download]: -> List[Download]:
"""Schedule all URLs for download. If one cannot be downloaded, none will be added.""" """Schedule all URLs for download. If one cannot be downloaded, none will be added."""
if not urls or not all(urls): if not urls or not all(urls):
raise ValueError('Download must have a URL') raise ValueError(f'Download must have a URL: {urls=}')
downloads = [] downloads = []
# Throws an error if no downloader is found. # Throws an error if no downloader is found.
@ -1093,9 +1093,9 @@ async def signal_download_download(download_id: int, download_url: str):
download.error = result.error if result.error else None download.error = result.error if result.error else None
download.next_download = download_manager.calculate_next_download(download, session) download.next_download = download_manager.calculate_next_download(download, session)
if result.downloads: urls = download.filter_excluded(result.downloads) if result.downloads else None
if urls:
worker_logger.info(f'Adding {len(result.downloads)} downloads from result of {download.url}') worker_logger.info(f'Adding {len(result.downloads)} downloads from result of {download.url}')
urls = download.filter_excluded(result.downloads)
download_manager.create_downloads(urls, session, downloader_name=download.sub_downloader, download_manager.create_downloads(urls, session, downloader_name=download.sub_downloader,
settings=result.settings) settings=result.settings)

View file

@ -4,7 +4,7 @@ from abc import ABC
from typing import List from typing import List
from wrolpi.common import get_media_directory, logger, background_task, get_download_info, \ from wrolpi.common import get_media_directory, logger, background_task, get_download_info, \
download_file download_file, trim_file_name
from wrolpi.db import get_db_session from wrolpi.db import get_db_session
from wrolpi.downloader import Downloader, Download, DownloadResult from wrolpi.downloader import Downloader, Download, DownloadResult
from wrolpi.errors import UnrecoverableDownloadError from wrolpi.errors import UnrecoverableDownloadError
@ -48,7 +48,7 @@ class FileDownloader(Downloader, ABC):
info = await get_download_info(download.url) info = await get_download_info(download.url)
# TODO verify that this output_path is exclusive. # TODO verify that this output_path is exclusive.
output_path = destination / info.name output_path = destination / trim_file_name(info.name)
try: try:
await download_file(download.url, output_path, info) await download_file(download.url, output_path, info)

View file

@ -626,6 +626,8 @@ async def refresh_files(paths: List[pathlib.Path] = None, send_events: bool = Tr
if send_events: if send_events:
Events.send_refresh_completed() Events.send_refresh_completed()
api_app.shared_ctx.refresh['counted_files'] = 0
async def apply_indexers(): async def apply_indexers():
"""Indexes any Files that have not yet been indexed by Modelers, or by previous calls of this function.""" """Indexes any Files that have not yet been indexed by Modelers, or by previous calls of this function."""

View file

@ -68,7 +68,10 @@ class DownloadSettings:
def __post_init__(self): def __post_init__(self):
from wrolpi.common import get_media_directory from wrolpi.common import get_media_directory
self.tag_names = [i.strip() for i in self.tag_names] if self.tag_names else [] self.tag_names = [i.strip() for i in self.tag_names] if self.tag_names else []
self.excluded_urls = [i.strip() for i in self.excluded_urls.split(',')] if self.excluded_urls else None if isinstance(self.excluded_urls, str):
self.excluded_urls = [i.strip() for i in self.excluded_urls.split(',')] if self.excluded_urls else None
else:
self.excluded_urls = [i.strip() for i in self.excluded_urls] if self.excluded_urls else None
self.destination = str(get_media_directory() / self.destination) if self.destination else None self.destination = str(get_media_directory() / self.destination) if self.destination else None
if self.suffix and not self.suffix.startswith('.'): if self.suffix and not self.suffix.startswith('.'):
raise ValidationError('suffix must start with .') raise ValidationError('suffix must start with .')

View file

@ -305,6 +305,17 @@ def test_escape_file_name(name, expected):
raise AssertionError(f'Escape of {repr(name)}: {repr(result)} != {repr(expected)}') raise AssertionError(f'Escape of {repr(name)}: {repr(result)} != {repr(expected)}')
@pytest.mark.parametrize('name,expected', [
('Channel_20240714_aaa.jpg', 'Channel_20240714_aaa.jpg'),
# Really long filename. The suffix is preserved.
('Channel_20240714_' + ('a' * 244) + '.jpg', 'Channel_20240714_' + ('a' * 159) + '.jpg'),
])
def test_trim_file_name(name, expected):
result = common.trim_file_name(name)
assert result == expected, f'Expected length of {len(expected)} but got length of {len(result)}'
assert len(result) <= 255, 'File name cannot be longer than 255 characters'
def test_truncate_object_bytes(): def test_truncate_object_bytes():
""" """
Objects can be truncated (lists will be shortened) so they will fit in tsvector columns. Objects can be truncated (lists will be shortened) so they will fit in tsvector columns.