Compare commits

...

25 Commits

Author SHA1 Message Date
meeb c6acd5378c move TIME_ZONE set by env var from local_settings to settings, resolves #462 2024-02-02 05:51:20 +11:00
meeb e7788eb8fb
Merge pull request #450 from InterN0te/main-delete-files-on-disk
Following Delete files on disk #426
2024-01-17 22:31:09 +11:00
meeb e4e0b48c0b
Merge pull request #460 from skayred/main
Optimized source page and dashboard loading
2024-01-17 22:29:59 +11:00
Dmitrii Savchenko 3573c1187f
Optimized source page and dashboard loading 2024-01-16 11:57:00 +02:00
meeb b11b667aff
Merge pull request #452 from ShaneBridges1234/patch-1
Update other-database-backends.md
2024-01-03 16:06:17 +11:00
Yottatron 1b581aa4ba
Update other-database-backends.md
Correct table new in SQL for MariaDB column compression.
2024-01-01 09:16:04 -08:00
meeb 7384c00713 fix typo in sponsorblock categories, remove dupe metadata flags, related to #362 2023-12-12 16:28:26 +11:00
meeb 4fdd172b05 tidy up and pass a serialised list through to youtube.download_media, may help with #362 2023-12-12 14:40:23 +11:00
Someone 9c18115032
Merge branch 'meeb:main' into main-delete-files-on-disk 2023-12-11 15:41:50 +01:00
meeb 6853c1fa76 fix tests 2023-12-11 13:42:33 +11:00
administrator ed07073cf4 Revert "Removed non-pertinent source option"
This reverts commit 46ba2593a2.

Restore option
2023-12-11 03:38:37 +01:00
administrator af94b37ee6 Revert "Removed non-pertinent source option"
This reverts commit ad1d49a835.

t cherry-pick 46a43b9

Restore option
2023-12-11 03:38:14 +01:00
administrator ad1d49a835 Removed non-pertinent source option 2023-12-11 03:25:17 +01:00
administrator 46ba2593a2 Removed non-pertinent source option 2023-12-11 03:19:43 +01:00
administrator 46a43b968a Rework delete method to delete all files matching filename
Remove Source folder if checkbox 'remove media' is checked
2023-12-11 02:29:57 +01:00
administrator 805a0eefbd Merge branch 'delete-files-on-disk' of https://github.com/sparklesmcfadden/tubesync into main-delete-files-on-disk
Merge from sparklesmcfadden:delete-files-on-disk
2023-12-11 00:19:37 +01:00
meeb 3a87b5779e
Merge pull request #448 from InterN0te/main-addNfoInfo
Added season and episode tags in NFO to get Jellyfin displaying correctly
2023-12-10 17:58:04 +11:00
Someone f86e72aa92
Optimization of episode calculation 2023-12-09 23:13:28 +01:00
Someone f550e32b5e
Fix secondary sorting on video key 2023-12-09 19:33:59 +01:00
Someone 034d877d6a
Add season and episode tags in NFO test 2023-12-09 17:56:32 +01:00
Someone b9b702ab85
Add season and episode tags in NFO 2023-12-09 17:55:17 +01:00
meeb c159c24d15
Merge pull request #443 from depuhitv/patch-1
compress sync_media table for mariadb
2023-12-04 18:05:02 +11:00
depuhitv 6c9772d573 fixed grammar 2023-12-04 16:26:46 +11:00
depuhitv 45b8b3f65b
compress sync_media table for mariadb
Added steps to compress sync_media table for mariadb.
For 1,608 records, I am seeing the size reduced from 642.8 MB to 55.8 MB
2023-12-04 16:18:10 +11:00
sparklesmcfadden 43cf532903 Adds option to delete files on disk when removing a media item from TubeSync 2023-10-21 20:54:21 -05:00
11 changed files with 129 additions and 61 deletions

View File

@ -79,6 +79,14 @@ entry in the container or stdout logs:
If you see a line similar to the above and the web interface loads, congratulations,
you are now using an external database server for your TubeSync data!
## Database Compression (For MariaDB)
With a lot of media files the `sync_media` table grows in size quickly.
You can save space using column compression using the following steps while using MariaDB:
1. Stop tubesync
2. Execute `ALTER TABLE sync_media MODIFY metadata LONGTEXT COMPRESSED;` on database tubesync
3. Start tunesync and confirm the connection still works.
## Docker Compose
If you're using Docker Compose and simply want to connect to another container with
@ -118,6 +126,7 @@ database before it can be written to. This file should contain:
CREATE DATABASE tubesync;
```
Then it must be mapped to `/docker-entrypoint-initdb.d/init.sql` for it
to be executed on first startup of the container. See the `tubesync-db`
volume mapping above for how to do this.

View File

@ -0,0 +1,17 @@
# Generated by pac
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0020_auto_20231024_1825'),
]
operations = [
migrations.AddField(
model_name='source',
name='delete_files_on_disk',
field=models.BooleanField(default=False, help_text='Delete files on disk when they are removed from TubeSync', verbose_name='delete files on disk'),
),
]

View File

@ -18,7 +18,7 @@ from common.utils import clean_filename
from .youtube import (get_media_info as get_youtube_media_info,
download_media as download_youtube_media)
from .utils import seconds_to_timestr, parse_media_format
from .matching import (get_best_combined_format, get_best_audio_format,
from .matching import (get_best_combined_format, get_best_audio_format,
get_best_video_format)
from .mediaservers import PlexMediaServer
from .fields import CommaSepChoiceField
@ -107,7 +107,6 @@ class Source(models.Model):
EXTENSION_MKV = 'mkv'
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_MKV)
# as stolen from: https://wiki.sponsor.ajay.app/w/Types / https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/postprocessor/sponsorblock.py
SPONSORBLOCK_CATEGORIES_CHOICES = (
('sponsor', 'Sponsor'),
@ -119,17 +118,16 @@ class Source(models.Model):
('interaction', 'Interaction Reminder'),
('music_offtopic', 'Non-Music Section'),
)
sponsorblock_categories = CommaSepChoiceField(
_(''),
possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES,
all_choice="all",
allow_all=True,
all_label="(all options)",
default="all",
help_text=_("Select the sponsorblocks you want to enforce")
)
sponsorblock_categories = CommaSepChoiceField(
_(''),
possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES,
all_choice='all',
allow_all=True,
all_label='(all options)',
default='all',
help_text=_('Select the sponsorblocks you want to enforce')
)
embed_metadata = models.BooleanField(
_('embed metadata'),
default=False,
@ -140,14 +138,12 @@ class Source(models.Model):
default=False,
help_text=_('Embed thumbnail into the file')
)
enable_sponsorblock = models.BooleanField(
_('enable sponsorblock'),
default=True,
help_text=_('Use SponsorBlock?')
)
# Fontawesome icons used for the source on the front end
ICONS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
@ -300,6 +296,11 @@ class Source(models.Model):
default=False,
help_text=_('Delete media that is no longer on this playlist')
)
delete_files_on_disk = models.BooleanField(
_('delete files on disk'),
default=False,
help_text=_('Delete files on disk when they are removed from TubeSync')
)
source_resolution = models.CharField(
_('source resolution'),
max_length=8,
@ -1236,7 +1237,6 @@ class Media(models.Model):
acodec = self.downloaded_audio_codec
if acodec is None:
raise TypeError() # nothing here.
acodec = acodec.lower()
if acodec == "mp4a":
return "audio/mp4"
@ -1245,7 +1245,6 @@ class Media(models.Model):
else:
# fall-fall-back.
return 'audio/ogg'
vcodec = vcodec.lower()
if vcodec == 'vp9':
return 'video/webm'
@ -1269,6 +1268,22 @@ class Media(models.Model):
showtitle.text = str(self.source.name).strip()
showtitle.tail = '\n '
nfo.append(showtitle)
# season = upload date year
season = nfo.makeelement('season', {})
if self.source.source_type == Source.SOURCE_TYPE_YOUTUBE_PLAYLIST:
# If it's a playlist, set season to 1
season.text = '1'
else:
# If it's not a playlist, set season to upload date year
season.text = str(self.upload_date.year) if self.upload_date else ''
season.tail = '\n '
nfo.append(season)
# episode = number of video in the year
episode = nfo.makeelement('episode', {})
episode_number = self.calculate_episode_number()
episode.text = str(episode_number) if episode_number else ''
episode.tail = '\n '
nfo.append(episode)
# ratings = media metadata youtube rating
value = nfo.makeelement('value', {})
value.text = str(self.rating)
@ -1376,8 +1391,8 @@ class Media(models.Model):
f'no valid format available')
# Download the media with youtube-dl
download_youtube_media(self.url, format_str, self.source.extension,
str(self.filepath), self.source.write_json,
self.source.sponsorblock_categories, self.source.embed_thumbnail,
str(self.filepath), self.source.write_json,
self.source.sponsorblock_categories.selected_choices, self.source.embed_thumbnail,
self.source.embed_metadata, self.source.enable_sponsorblock,
self.source.write_subtitles, self.source.auto_subtitles,self.source.sub_langs )
# Return the download paramaters
@ -1393,6 +1408,19 @@ class Media(models.Model):
f'has no indexer')
return indexer(self.url)
def calculate_episode_number(self):
if self.source.source_type == Source.SOURCE_TYPE_YOUTUBE_PLAYLIST:
sorted_media = Media.objects.filter(source=self.source)
else:
self_year = self.upload_date.year if self.upload_date else self.created.year
filtered_media = Media.objects.filter(source=self.source, published__year=self_year)
sorted_media = sorted(filtered_media, key=lambda x: (x.upload_date, x.key))
position_counter = 1
for media in sorted_media:
if media == self:
return position_counter
position_counter += 1
class MediaServer(models.Model):
'''

View File

@ -1,4 +1,5 @@
import os
import glob
from django.conf import settings
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
from django.dispatch import receiver
@ -74,6 +75,7 @@ def source_pre_delete(sender, instance, **kwargs):
media.delete()
@receiver(post_delete, sender=Source)
def source_post_delete(sender, instance, **kwargs):
# Triggered after a source is deleted
@ -222,6 +224,16 @@ def media_pre_delete(sender, instance, **kwargs):
if thumbnail_url:
delete_task_by_media('sync.tasks.download_media_thumbnail',
(str(instance.pk), thumbnail_url))
if instance.source.delete_files_on_disk and (instance.media_file or instance.thumb):
# Delete all media files if it contains filename
filepath = instance.media_file.path if instance.media_file else instance.thumb.path
barefilepath, fileext = os.path.splitext(filepath)
# Get all files that start with the bare file path
all_related_files = glob.glob(f'{barefilepath}.*')
for file in all_related_files:
log.info(f'Deleting file for: {instance} path: {file}')
delete_file(file)
@receiver(post_delete, sender=Media)

View File

@ -9,8 +9,8 @@
<p>
Are you sure you want to delete this source? Deleting a source is permanent.
By default, deleting a source does not delete any saved media files. You can
tick the &quot;also delete downloaded media&quot; checkbox to also remove save
media when you delete the source. Deleting a source cannot be undone.
<strong>tick the &quot;also delete downloaded media&quot; checkbox to also remove directory {{ source.directory_path }}
</strong>when you delete the source. Deleting a source cannot be undone.
</p>
</div>
</div>

View File

@ -122,6 +122,10 @@
<tr title="Delete media that is no longer on this playlist?">
<td class="hide-on-small-only">Delete removed media</td>
<td><span class="hide-on-med-and-up">Delete removed media<br></span><strong>{% if source.delete_removed_media %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr>
<tr title="Delete files on disk when they are removed from TubeSync?">
<td class="hide-on-small-only">Delete files on disk</td>
<td><span class="hide-on-med-and-up">Delete files on disk<br></span><strong>{% if source.delete_files_on_disk %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
</tr>
{% if source.delete_old_media and source.days_to_keep > 0 %}
<tr title="Days after which your media from this source will be locally deleted">

View File

@ -661,6 +661,8 @@ class MediaTestCase(TestCase):
'<episodedetails>',
' <title>no fancy stuff title</title>',
' <showtitle>testname</showtitle>',
' <season>2017</season>',
' <episode></episode>',
' <ratings>',
' <rating default="True" max="5" name="youtube">',
' <value>1.2345</value>',

View File

@ -1,7 +1,9 @@
import glob
import os
import json
from base64 import b64decode
import pathlib
import shutil
import sys
from django.conf import settings
from django.http import FileResponse, Http404, HttpResponseNotFound, HttpResponseRedirect
@ -59,7 +61,7 @@ class DashboardView(TemplateView):
# Disk usage
disk_usage = Media.objects.filter(
downloaded=True, downloaded_filesize__isnull=False
).aggregate(Sum('downloaded_filesize'))
).defer('metadata').aggregate(Sum('downloaded_filesize'))
data['disk_usage_bytes'] = disk_usage['downloaded_filesize__sum']
if not data['disk_usage_bytes']:
data['disk_usage_bytes'] = 0
@ -71,11 +73,11 @@ class DashboardView(TemplateView):
# Latest downloads
data['latest_downloads'] = Media.objects.filter(
downloaded=True, downloaded_filesize__isnull=False
).order_by('-download_date')[:10]
).defer('metadata').order_by('-download_date')[:10]
# Largest downloads
data['largest_downloads'] = Media.objects.filter(
downloaded=True, downloaded_filesize__isnull=False
).order_by('-downloaded_filesize')[:10]
).defer('metadata').order_by('-downloaded_filesize')[:10]
# UID and GID
data['uid'] = os.getuid()
data['gid'] = os.getgid()
@ -296,9 +298,9 @@ class EditSourceMixin:
model = Source
fields = ('source_type', 'key', 'name', 'directory', 'filter_text', 'media_format',
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
'delete_removed_media', 'days_to_keep', 'source_resolution', 'source_vcodec',
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails',
'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
'delete_removed_media', 'delete_files_on_disk', 'days_to_keep', 'source_resolution',
'source_vcodec', 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback',
'copy_thumbnails', 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles',
'auto_subtitles', 'sub_langs')
errors = {
@ -404,7 +406,7 @@ class SourceView(DetailView):
error_message = get_error_message(error)
setattr(error, 'error_message', error_message)
data['errors'].append(error)
data['media'] = Media.objects.filter(source=self.object).order_by('-published')
data['media'] = Media.objects.filter(source=self.object).order_by('-published').defer('metadata')
return data
@ -435,14 +437,13 @@ class DeleteSourceView(DeleteView, FormMixin):
source = self.get_object()
for media in Media.objects.filter(source=source):
if media.media_file:
# Delete the media file
delete_file(media.media_file.path)
# Delete thumbnail copy if it exists
delete_file(media.thumbpath)
# Delete NFO file if it exists
delete_file(media.nfopath)
# Delete JSON file if it exists
delete_file(media.jsonpath)
file_path = media.media_file.path
matching_files = glob.glob(os.path.splitext(file_path)[0] + '.*')
for file in matching_files:
delete_file(file)
directory_path = source.directory_path
if os.path.exists(directory_path):
shutil.rmtree(directory_path, True)
return super().post(request, *args, **kwargs)
def get_success_url(self):
@ -653,12 +654,13 @@ class MediaSkipView(FormView, SingleObjectMixin):
delete_task_by_media('sync.tasks.download_media', (str(self.object.pk),))
# If the media file exists on disk, delete it
if self.object.media_file_exists:
delete_file(self.object.media_file.path)
self.object.media_file = None
# If the media has an associated thumbnail copied, also delete it
delete_file(self.object.thumbpath)
# If the media has an associated NFO file with it, also delete it
delete_file(self.object.nfopath)
# Delete all files which contains filename
filepath = self.object.media_file.path
barefilepath, fileext = os.path.splitext(filepath)
# Get all files that start with the bare file path
all_related_files = glob.glob(f'{barefilepath}.*')
for file in all_related_files:
delete_file(file)
# Reset all download data
self.object.metadata = None
self.object.downloaded = False

View File

@ -1,5 +1,5 @@
'''
Wrapper for the youtube-dl library. Used so if there are any library interface
Wrapper for the yt-dlp library. Used so if there are any library interface
updates we only need to udpate them in one place.
'''
@ -64,9 +64,9 @@ def get_media_info(url):
return response
def download_media(url, media_format, extension, output_file, info_json,
sponsor_categories="all",
embed_thumbnail=False, embed_metadata=False, skip_sponsors=True,
def download_media(url, media_format, extension, output_file, info_json,
sponsor_categories=None,
embed_thumbnail=False, embed_metadata=False, skip_sponsors=True,
write_subtitles=False, auto_subtitles=False, sub_langs='en'):
'''
Downloads a YouTube URL to a file on disk.
@ -74,7 +74,7 @@ def download_media(url, media_format, extension, output_file, info_json,
def hook(event):
filename = os.path.basename(event['filename'])
if event.get('downloaded_bytes') is None or event.get('total_bytes') is None:
return None
@ -106,8 +106,8 @@ def download_media(url, media_format, extension, output_file, info_json,
f'{total_size_str} in {elapsed_str}')
else:
log.warn(f'[youtube-dl] unknown event: {str(event)}')
hook.download_progress = 0
hook.download_progress = 0
ytopts = {
'format': media_format,
'merge_output_format': extension,
@ -120,29 +120,25 @@ def download_media(url, media_format, extension, output_file, info_json,
'writeautomaticsub': auto_subtitles,
'subtitleslangs': sub_langs.split(','),
}
if not sponsor_categories:
sponsor_categories = []
sbopt = {
'key': 'SponsorBlock',
'categories': [sponsor_categories]
'categories': sponsor_categories
}
ffmdopt = {
'key': 'FFmpegMetadata',
'add_chapters': True,
'add_metadata': True
'add_chapters': embed_metadata,
'add_metadata': embed_metadata
}
opts = get_yt_opts()
if embed_thumbnail:
ytopts['postprocessors'].append({'key': 'EmbedThumbnail'})
if embed_metadata:
ffmdopt["add_metadata"] = True
if skip_sponsors:
ytopts['postprocessors'].append(sbopt)
ytopts['postprocessors'].append(ffmdopt)
opts.update(ytopts)
with yt_dlp.YoutubeDL(opts) as y:
try:
return y.download([url])

View File

@ -25,9 +25,6 @@ DEBUG = True if os.getenv('TUBESYNC_DEBUG', False) else False
FORCE_SCRIPT_NAME = os.getenv('DJANGO_FORCE_SCRIPT_NAME', DJANGO_URL_PREFIX)
TIME_ZONE = os.getenv('TZ', 'UTC')
database_dict = {}
database_connection_env = os.getenv('DATABASE_CONNECTION', '')
if database_connection_env:

View File

@ -1,3 +1,4 @@
import os
from pathlib import Path
@ -96,7 +97,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
TIME_ZONE = os.getenv('TZ', 'UTC')
USE_I18N = True
USE_L10N = True
USE_TZ = True