mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
337 lines
13 KiB
Python
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))
|