wrolpi/scripts/delete_similarly_named_videos.py

337 lines
13 KiB
Python

#! /usr/bin/env python3
"""
A quick and dirty script which will query the DB for all videos and search for similarly named videos within a Channel.
WROLPi can sometimes download a video more than once if the Channel changes their name, or,
"""
import argparse
import asyncio
import difflib
import functools
import os.path
import re
import sys
from difflib import SequenceMatcher
from typing import List, Generator, Optional, Dict
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.db import get_db_curs, get_db_session
from wrolpi.files.models import FileGroup
MINIMUM_RANK = 0.8
def similar(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def get_all_channels(curs) -> List[int]:
curs.execute('SELECT id FROM channel')
return [i[0] for i in curs.fetchall()]
@functools.lru_cache
def get_channel_name(channel_id: int) -> Optional[str]:
with get_db_session() as session:
channel: Channel = session.query(Channel).filter_by(id=channel_id).one()
if channel.info_json:
return channel.info_json['channel']
@functools.lru_cache
def video_file_matches_channel_info_json(video: Video) -> bool:
if not video.source_id or not video.channel:
return False
if entry := video.get_channel_entry():
return entry['title'] in str(video.video_path)
return False
def find_similar_videos(channel_id: int, minimum_rank: float = MINIMUM_RANK, directory: str = None) \
-> Generator[List[Video], None, None]:
consumed_ids = set()
with get_db_session() as session:
query = session.query(Video).filter(Video.channel_id == channel_id)
if directory:
query = query.join(FileGroup).filter(FileGroup.primary_path.like(f'{directory}%'))
videos: List[Video] = query.all()
videos = sorted(videos, key=lambda i: i.video_path)
for link in chain(videos, 5):
link: List[Video]
link = [i for i in link if i.id not in consumed_ids and i.file_group.title]
if not link:
continue
vid1, *vids = link
vid1_title = vid1.file_group.title
similar_videos = [i for i in vids if similar(i.file_group.title, vid1_title) >= minimum_rank]
if not similar_videos:
continue
similar_videos += [vid1, ]
# Do not compare against these videos again.
consumed_ids |= {i.id for i in similar_videos}
# Yield the videos that share similar titles.
yield similar_videos
async def rank_video_quality(video: Video, sizes_by_source_id: Dict[str, int]) -> int:
"""Returns a higher integer the more quality the Video record is. A video with info_json is more valuable than
one without, etc."""
rank = 0
# These are more valuable.
if video.file_group.url:
rank += 2
if video.source_id:
rank += 2
if video.info_json_path and video.info_json_path.is_file() and video.info_json_path.stat().st_size:
rank += 2
if video_file_matches_channel_info_json(video):
rank += 2
if video.get_comments():
rank += 2
# These are more common.
if video.channel_id:
rank += 1
if video.caption_paths:
rank += 1
if video.poster_path and video.poster_path.is_file() and video.poster_path.stat().st_size:
rank += 1
if video.ffprobe_json or await video.get_ffprobe_json():
rank += 1
largest_size = sizes_by_source_id.get(video.source_id)
if largest_size and video.file_group.size == largest_size:
rank += 1
_, _, _, video_title = parse_video_file_name(video.video_path)
video_title = video_title.strip()
if video.file_group.title == video_title:
# Video title from JSON matches the video file name.
rank += 1
channel_name = get_channel_name(video.channel_id)
if channel_name and channel_name in str(video.video_path):
# The Channel's name is in the video's path. Channel names change sometimes...
rank += 2
return rank
def delete_video(video_id: int):
with get_db_session(commit=True) as session:
video: Video = session.query(Video).filter_by(id=video_id).one()
video.delete()
class SkipChannel(Exception):
pass
def get_different_characters(a: str, b: str) -> str:
changes = ''
for i, s in enumerate(difflib.ndiff(a, b)):
if s[0] == ' ':
# Ignore same-characters.
continue
elif s[0] == '+' or s[0] == '-':
# Return characters that are different.
changes += s[-1]
return changes
PART_MATCH = re.compile(r'(.*)\b((?:p(?:art|t)?.?\s?(?:\d+|[ivx]+)+)|[ivx]+)\b(.*)', re.IGNORECASE)
def remove_part_from_title(title: str) -> str:
title = title.lower()
if match := PART_MATCH.match(title):
prefix, part, suffix = match.groups()
return prefix + suffix
return title
def largest_size_by_source_id(similar_videos: List[Video]) -> Dict[str, int]:
"""Videos that are larger are probably better quality, prefer to preserve larger videos."""
sizes = dict()
for video in similar_videos:
source_id = video.source_id
old_size = sizes.get(source_id, 0)
sizes[source_id] = max(old_size, video.file_group.size)
return sizes
async def handle_user_delete_duplicates(similar_videos: List[Video], video_url: str = None,
delete_lower_ranked_automatically: bool = False):
similar_videos = similar_videos.copy()
while len(similar_videos) > 1:
all_changes = ''
for i in similar_videos:
a = remove_part_from_title(i.file_group.title)
for j in similar_videos:
if i.id == j.id:
continue
b = remove_part_from_title(j.file_group.title)
all_changes += get_different_characters(a, b)
# Ignore space changes.
all_changes = ''.join(i for i in all_changes if i != ' ' and i != '.')
if all_changes.isdigit():
print(f'\nSkipping {similar_videos[0]} and similar because they are parts')
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.
sizes_by_source_id = largest_size_by_source_id(similar_videos)
print('\n')
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)
for rank, video in ranked_videos:
fg = video.file_group
id_, title, path, source_id, size = video.id, fg.title, video.video_path, video.source_id, fg.size
size = f'{size // 1_048_576} MB'
path = get_relative_to_media_directory(path)
if video_url:
print(f'{rank} {repr(title)} {size} {repr(str(path.name))} {source_id} {id_} {video_url + str(id_)}')
else:
print(f'{rank} {repr(title)} {size} {repr(str(path.name))} {source_id} {id_}')
# Detect re-uploaded videos.
# If video with exact title is uploaded day(s) later, then suggest deleting the oldest video.
unique_ranks = {i[0] for i in ranked_videos}
if len(unique_ranks) > 1:
# Suggest to delete the lowest video.
_, lowest_video = min(ranked_videos, key=lambda i: i[0])
if delete_lower_ranked_automatically:
delete_video(lowest_video.id)
index = similar_videos.index(lowest_video)
del similar_videos[index]
continue
response = input('Delete the lowest ranked video? Merge? (y/m/N)').lower()
if response == 'y':
# Don't need to confirm when the video has been ranked low.
delete_video(lowest_video.id)
index = similar_videos.index(lowest_video)
del similar_videos[index]
elif response == 'm':
success = await handle_merge(ranked_videos)
if success:
break
elif response == 'n' or response == '':
break
elif response == 'c':
raise SkipChannel()
else:
# All videos share the same rank.
response = input('Delete which video? (starting at 0..., n to skip)')
if response.isdigit():
index = int(response)
_, to_delete = ranked_videos[index]
if input(f'\nConfirm delete: {to_delete} ').lower() == 'y':
delete_video(to_delete.id)
del similar_videos[index]
elif response == 'n' or response == 'N':
break
elif response == 'c':
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,
directory: str = None, delete_lower_ranked_automatically: bool = False):
with get_db_curs() as curs:
if channel_id:
for videos in find_similar_videos(channel_id, minimum_rank, directory):
await handle_user_delete_duplicates(videos, video_url, delete_lower_ranked_automatically)
else:
channel_ids = get_all_channels(curs)
for channel_id in channel_ids:
for videos in find_similar_videos(channel_id, minimum_rank, directory):
try:
await handle_user_delete_duplicates(videos, video_url, delete_lower_ranked_automatically)
except SkipChannel:
break
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('-c', '--channel-id', type=int,
help='The ID of the channel to search. If empty, all channels will be searched.')
parser.add_argument('-r', '--rank', type=float, default=MINIMUM_RANK,
help=f'The minimum similarity between the titles. Default: {MINIMUM_RANK}')
parser.add_argument('-u', '--video-url', default='http://127.0.0.1/videos/video/',
help='The URL where the videos can be viewed.')
parser.add_argument('-d', '--directory',
help='Only search videos in this directory.')
parser.add_argument('-y', '--yes', action='store_true', default=False,
help='Automatically delete lower-ranked duplicate videos.')
args = parser.parse_args()
loop = asyncio.new_event_loop()
loop.run_until_complete(main(args.channel_id, args.rank, args.video_url, args.directory, args.yes))