Compare commits

..

No commits in common. "main" and "v0.13.2" have entirely different histories.

19 changed files with 84 additions and 298 deletions

View File

@ -4,7 +4,6 @@ env:
IMAGE_NAME: tubesync IMAGE_NAME: tubesync
on: on:
workflow_dispatch:
push: push:
branches: branches:
- main - main

View File

@ -2,8 +2,8 @@ FROM debian:bookworm-slim
ARG TARGETPLATFORM ARG TARGETPLATFORM
ARG S6_VERSION="3.1.5.0" ARG S6_VERSION="3.1.5.0"
ARG FFMPEG_DATE="autobuild-2023-11-29-14-19" ARG FFMPEG_DATE="autobuild-2023-11-14-14-18"
ARG FFMPEG_VERSION="112875-g47e214245b" ARG FFMPEG_VERSION="112750-g6d60cc7baf"
ENV DEBIAN_FRONTEND="noninteractive" \ ENV DEBIAN_FRONTEND="noninteractive" \
HOME="/root" \ HOME="/root" \
@ -27,8 +27,8 @@ RUN export ARCH=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.xz" ;; \ "linux/arm64") echo "https://github.com/just-containers/s6-overlay/releases/download/v${S6_VERSION}/s6-overlay-aarch64.tar.xz" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
export FFMPEG_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \ export FFMPEG_EXPECTED_SHA256=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "36bac8c527bf390603416f749ab0dd860142b0a66f0865b67366062a9c286c8b" ;; \ "linux/amd64") echo "d905684195f16412d8ee4a61a5a32d4bea530b4f93260e800b5a74904f6a1528" ;; \
"linux/arm64") echo "8f36e45d99d2367a5c0c220ee3164fa48f4f0cec35f78204ccced8dc303bfbdc" ;; \ "linux/arm64") echo "5fdbf8d83d05b39d3e1cd666d485340115bc31cfc686993dcb77f99d1b35751e" ;; \
*) echo "" ;; esac) && \ *) echo "" ;; esac) && \
export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \ export FFMPEG_DOWNLOAD=$(case ${TARGETPLATFORM:-linux/amd64} in \
"linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linux64-gpl.tar.xz" ;; \ "linux/amd64") echo "https://github.com/yt-dlp/FFmpeg-Builds/releases/download/${FFMPEG_DATE}/ffmpeg-N-${FFMPEG_VERSION}-linux64-gpl.tar.xz" ;; \

View File

@ -29,10 +29,6 @@ runcontainer:
$(docker) run --rm --name $(name) --env-file dev.env --log-opt max-size=50m -ti -p 4848:4848 $(image) $(docker) run --rm --name $(name) --env-file dev.env --log-opt max-size=50m -ti -p 4848:4848 $(image)
stopcontainer:
$(docker) stop $(name)
test: build test: build
cd tubesync && $(python) manage.py test --verbosity=2 && cd .. cd tubesync && $(python) manage.py test --verbosity=2 && cd ..

View File

@ -78,55 +78,3 @@ entry in the container or stdout logs:
If you see a line similar to the above and the web interface loads, congratulations, 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! 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
the DB for the performance benefits, a configuration like this would be enough:
```
tubesync-db:
image: postgres:15.2
container_name: tubesync-db
restart: unless-stopped
volumes:
- /<path/to>/init.sql:/docker-entrypoint-initdb.d/init.sql
- /<path/to>/tubesync-db:/var/lib/postgresql/data
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=testpassword
tubesync:
image: ghcr.io/meeb/tubesync:latest
container_name: tubesync
restart: unless-stopped
ports:
- 4848:4848
volumes:
- /<path/to>/tubesync/config:/config
- /<path/to>/YouTube:/downloads
environment:
- DATABASE_CONNECTION=postgresql://postgres:testpassword@tubesync-db:5432/tubesync
depends_on:
- tubesync-db
```
Note that an `init.sql` file is needed to initialize the `tubesync`
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

@ -1,14 +1,10 @@
import logging import logging
from django.conf import settings
logging_level = logging.DEBUG if settings.DEBUG else logging.INFO
log = logging.getLogger('tubesync') log = logging.getLogger('tubesync')
log.setLevel(logging_level) log.setLevel(logging.DEBUG)
ch = logging.StreamHandler() ch = logging.StreamHandler()
ch.setLevel(logging_level) ch.setLevel(logging.DEBUG)
formatter = logging.Formatter('%(asctime)s [%(name)s/%(levelname)s] %(message)s') formatter = logging.Formatter('%(asctime)s [%(name)s/%(levelname)s] %(message)s')
ch.setFormatter(formatter) ch.setFormatter(formatter)
log.addHandler(ch) log.addHandler(ch)

View File

@ -3,7 +3,7 @@
<div class="col s12"> <div class="col s12">
<div class="pagination"> <div class="pagination">
{% for i in paginator.page_range %} {% for i in paginator.page_range %}
<a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}{% if show_skipped %}&show_skipped=yes{% endif %}{% if only_skipped %}&only_skipped=yes{% endif %}">{{ i }}</a> <a class="pagenum{% if i == page_obj.number %} currentpage{% endif %}" href="?{% if filter %}filter={{ filter }}&{% endif %}page={{ i }}{% if show_skipped %}&show_skipped=yes{% endif %}">{{ i }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>

View File

@ -1,29 +0,0 @@
# Generated by Django 3.2.22 on 2023-10-24 17:25
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('sync', '0019_add_delete_removed_media'),
]
operations = [
migrations.AddField(
model_name='source',
name='filter_text',
field=models.CharField(blank=True, default='', help_text='Regex compatible filter string for video titles', max_length=100, verbose_name='filter string'),
),
migrations.AlterField(
model_name='source',
name='auto_subtitles',
field=models.BooleanField(default=False, help_text='Accept auto-generated subtitles', verbose_name='accept auto-generated subs'),
),
migrations.AlterField(
model_name='source',
name='sub_langs',
field=models.CharField(default='en', help_text='List of subtitles langs to download, comma-separated. Example: en,fr or all,-fr,-live_chat', max_length=30, validators=[django.core.validators.RegexValidator(message='Subtitle langs must be a comma-separated list of langs. example: en,fr or all,-fr,-live_chat', regex='^(\\-?[\\_\\.a-zA-Z]+,)*(\\-?[\\_\\.a-zA-Z]+){1}$')], verbose_name='subs langs'),
),
]

View File

@ -1,17 +0,0 @@
# 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

@ -1,7 +1,6 @@
import os import os
import uuid import uuid
import json import json
import re
from xml.etree import ElementTree from xml.etree import ElementTree
from collections import OrderedDict from collections import OrderedDict
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -18,7 +17,7 @@ from common.utils import clean_filename
from .youtube import (get_media_info as get_youtube_media_info, from .youtube import (get_media_info as get_youtube_media_info,
download_media as download_youtube_media) download_media as download_youtube_media)
from .utils import seconds_to_timestr, parse_media_format 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) get_best_video_format)
from .mediaservers import PlexMediaServer from .mediaservers import PlexMediaServer
from .fields import CommaSepChoiceField from .fields import CommaSepChoiceField
@ -107,6 +106,7 @@ class Source(models.Model):
EXTENSION_MKV = 'mkv' EXTENSION_MKV = 'mkv'
EXTENSIONS = (EXTENSION_M4A, EXTENSION_OGG, EXTENSION_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 # 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 = ( SPONSORBLOCK_CATEGORIES_CHOICES = (
('sponsor', 'Sponsor'), ('sponsor', 'Sponsor'),
@ -118,16 +118,17 @@ class Source(models.Model):
('interaction', 'Interaction Reminder'), ('interaction', 'Interaction Reminder'),
('music_offtopic', 'Non-Music Section'), ('music_offtopic', 'Non-Music Section'),
) )
sponsorblock_categories = CommaSepChoiceField( sponsorblock_categories = CommaSepChoiceField(
_(''), _(''),
possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES, possible_choices=SPONSORBLOCK_CATEGORIES_CHOICES,
all_choice='all', all_choice="all",
allow_all=True, allow_all=True,
all_label='(all options)', all_label="(all options)",
default='all', default="all",
help_text=_('Select the sponsorblocks you want to enforce') help_text=_("Select the sponsorblocks you want to enforce")
) )
embed_metadata = models.BooleanField( embed_metadata = models.BooleanField(
_('embed metadata'), _('embed metadata'),
default=False, default=False,
@ -138,12 +139,14 @@ class Source(models.Model):
default=False, default=False,
help_text=_('Embed thumbnail into the file') help_text=_('Embed thumbnail into the file')
) )
enable_sponsorblock = models.BooleanField( enable_sponsorblock = models.BooleanField(
_('enable sponsorblock'), _('enable sponsorblock'),
default=True, default=True,
help_text=_('Use SponsorBlock?') help_text=_('Use SponsorBlock?')
) )
# Fontawesome icons used for the source on the front end # Fontawesome icons used for the source on the front end
ICONS = { ICONS = {
SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>', SOURCE_TYPE_YOUTUBE_CHANNEL: '<i class="fab fa-youtube"></i>',
@ -284,23 +287,11 @@ class Source(models.Model):
help_text=_('If "delete old media" is ticked, the number of days after which ' help_text=_('If "delete old media" is ticked, the number of days after which '
'to automatically delete media') 'to automatically delete media')
) )
filter_text = models.CharField(
_('filter string'),
max_length=100,
default='',
blank=True,
help_text=_('Regex compatible filter string for video titles')
)
delete_removed_media = models.BooleanField( delete_removed_media = models.BooleanField(
_('delete removed media'), _('delete removed media'),
default=False, default=False,
help_text=_('Delete media that is no longer on this playlist') 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 = models.CharField(
_('source resolution'), _('source resolution'),
max_length=8, max_length=8,
@ -547,11 +538,6 @@ class Source(models.Model):
except Exception as e: except Exception as e:
return '' return ''
def is_regex_match(self, media_item_title):
if not self.filter_text:
return True
return bool(re.search(self.filter_text, media_item_title))
def index_media(self): def index_media(self):
''' '''
Index the media source returning a list of media metadata as dicts. Index the media source returning a list of media metadata as dicts.
@ -1237,6 +1223,7 @@ class Media(models.Model):
acodec = self.downloaded_audio_codec acodec = self.downloaded_audio_codec
if acodec is None: if acodec is None:
raise TypeError() # nothing here. raise TypeError() # nothing here.
acodec = acodec.lower() acodec = acodec.lower()
if acodec == "mp4a": if acodec == "mp4a":
return "audio/mp4" return "audio/mp4"
@ -1245,6 +1232,7 @@ class Media(models.Model):
else: else:
# fall-fall-back. # fall-fall-back.
return 'audio/ogg' return 'audio/ogg'
vcodec = vcodec.lower() vcodec = vcodec.lower()
if vcodec == 'vp9': if vcodec == 'vp9':
return 'video/webm' return 'video/webm'
@ -1268,22 +1256,6 @@ class Media(models.Model):
showtitle.text = str(self.source.name).strip() showtitle.text = str(self.source.name).strip()
showtitle.tail = '\n ' showtitle.tail = '\n '
nfo.append(showtitle) 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 # ratings = media metadata youtube rating
value = nfo.makeelement('value', {}) value = nfo.makeelement('value', {})
value.text = str(self.rating) value.text = str(self.rating)
@ -1391,8 +1363,8 @@ class Media(models.Model):
f'no valid format available') f'no valid format available')
# Download the media with youtube-dl # Download the media with youtube-dl
download_youtube_media(self.url, format_str, self.source.extension, download_youtube_media(self.url, format_str, self.source.extension,
str(self.filepath), self.source.write_json, str(self.filepath), self.source.write_json,
self.source.sponsorblock_categories.selected_choices, self.source.embed_thumbnail, self.source.sponsorblock_categories, self.source.embed_thumbnail,
self.source.embed_metadata, self.source.enable_sponsorblock, self.source.embed_metadata, self.source.enable_sponsorblock,
self.source.write_subtitles, self.source.auto_subtitles,self.source.sub_langs ) self.source.write_subtitles, self.source.auto_subtitles,self.source.sub_langs )
# Return the download paramaters # Return the download paramaters
@ -1408,19 +1380,6 @@ class Media(models.Model):
f'has no indexer') f'has no indexer')
return indexer(self.url) 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): class MediaServer(models.Model):
''' '''

View File

@ -1,5 +1,4 @@
import os import os
import glob
from django.conf import settings from django.conf import settings
from django.db.models.signals import pre_save, post_save, pre_delete, post_delete from django.db.models.signals import pre_save, post_save, pre_delete, post_delete
from django.dispatch import receiver from django.dispatch import receiver
@ -75,7 +74,6 @@ def source_pre_delete(sender, instance, **kwargs):
media.delete() media.delete()
@receiver(post_delete, sender=Source) @receiver(post_delete, sender=Source)
def source_post_delete(sender, instance, **kwargs): def source_post_delete(sender, instance, **kwargs):
# Triggered after a source is deleted # Triggered after a source is deleted
@ -98,14 +96,14 @@ def media_post_save(sender, instance, created, **kwargs):
# If the media is skipped manually, bail. # If the media is skipped manually, bail.
if instance.manual_skip: if instance.manual_skip:
return return
# Triggered after media is saved # Triggered after media is saved
cap_changed = False cap_changed = False
can_download_changed = False can_download_changed = False
# Reset the skip flag if the download cap has changed if the media has not # Reset the skip flag if the download cap has changed if the media has not
# already been downloaded # already been downloaded
if not instance.downloaded and instance.metadata: if not instance.downloaded:
max_cap_age = instance.source.download_cap_date max_cap_age = instance.source.download_cap_date
filter_text = instance.source.filter_text.strip()
published = instance.published published = instance.published
if not published: if not published:
if not instance.skip: if not instance.skip:
@ -119,20 +117,11 @@ def media_post_save(sender, instance, created, **kwargs):
else: else:
if max_cap_age: if max_cap_age:
if published > max_cap_age and instance.skip: if published > max_cap_age and instance.skip:
if filter_text: # Media was published after the cap date but is set to be skipped
if instance.source.is_regex_match(instance.title): log.info(f'Media: {instance.source} / {instance} has a valid '
log.info(f'Media: {instance.source} / {instance} has a valid ' f'publishing date, marking to be unskipped')
f'publishing date and title filter, marking to be unskipped') instance.skip = False
instance.skip = False cap_changed = True
cap_changed = True
else:
log.debug(f'Media: {instance.source} / {instance} has a valid publishing date '
f'but failed the title filter match, already marked skipped')
else:
log.info(f'Media: {instance.source} / {instance} has a valid '
f'publishing date, marking to be unskipped')
instance.skip = False
cap_changed = True
elif published <= max_cap_age and not instance.skip: elif published <= max_cap_age and not instance.skip:
log.info(f'Media: {instance.source} / {instance} is too old for ' log.info(f'Media: {instance.source} / {instance} is too old for '
f'the download cap date, marking to be skipped') f'the download cap date, marking to be skipped')
@ -141,20 +130,10 @@ def media_post_save(sender, instance, created, **kwargs):
else: else:
if instance.skip: if instance.skip:
# Media marked to be skipped but source download cap removed # Media marked to be skipped but source download cap removed
if filter_text: log.info(f'Media: {instance.source} / {instance} has a valid '
if instance.source.is_regex_match(instance.title): f'publishing date, marking to be unskipped')
log.info(f'Media: {instance.source} / {instance} has a valid ' instance.skip = False
f'publishing date and title filter, marking to be unskipped') cap_changed = True
instance.skip = False
cap_changed = True
else:
log.info(f'Media: {instance.source} / {instance} has a valid publishing date '
f'but failed the title filter match, already marked skipped')
else:
log.debug(f'Media: {instance.source} / {instance} has a valid publishing date and '
f'is already marked as not to be skipped')
cap_changed = False
# Recalculate the "can_download" flag, this may # Recalculate the "can_download" flag, this may
# need to change if the source specifications have been changed # need to change if the source specifications have been changed
if instance.metadata: if instance.metadata:
@ -224,16 +203,6 @@ def media_pre_delete(sender, instance, **kwargs):
if thumbnail_url: if thumbnail_url:
delete_task_by_media('sync.tasks.download_media_thumbnail', delete_task_by_media('sync.tasks.download_media_thumbnail',
(str(instance.pk), thumbnail_url)) (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) @receiver(post_delete, sender=Media)

View File

@ -231,9 +231,11 @@ def download_media_metadata(media_id):
log.error(f'Task download_media_metadata(pk={media_id}) called but no ' log.error(f'Task download_media_metadata(pk={media_id}) called but no '
f'media exists with ID: {media_id}') f'media exists with ID: {media_id}')
return return
if media.manual_skip: if media.manual_skip:
log.info(f'Task for ID: {media_id} skipped, due to task being manually skipped.') log.info(f'Task for ID: {media_id} skipped, due to task being manually skipped.')
return return
source = media.source source = media.source
metadata = media.index_metadata() metadata = media.index_metadata()
media.metadata = json.dumps(metadata, default=json_serial) media.metadata = json.dumps(metadata, default=json_serial)
@ -252,11 +254,6 @@ def download_media_metadata(media_id):
log.warn(f'Media: {source} / {media} is older than cap age ' log.warn(f'Media: {source} / {media} is older than cap age '
f'{max_cap_age}, skipping') f'{max_cap_age}, skipping')
media.skip = True media.skip = True
# If the source has a search filter, check the video title matches the filter
if source.filter_text and not source.is_regex_match(media.title):
# Filter text not found in the media title. Accepts regex string, blank search filter results in this returning false
log.warn(f'Media: {source} / {media} does not match {source.filter_text}, skipping')
media.skip = True
# If the source has a cut-off check the upload date is within the allowed delta # If the source has a cut-off check the upload date is within the allowed delta
if source.delete_old_media and source.days_to_keep > 0: if source.delete_old_media and source.days_to_keep > 0:
if not isinstance(media.published, datetime): if not isinstance(media.published, datetime):

View File

@ -64,5 +64,5 @@
</div> </div>
{% endfor %} {% endfor %}
</div> </div>
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped only_skipped=only_skipped%} {% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped %}
{% endblock %} {% endblock %}

View File

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

View File

@ -43,10 +43,6 @@
<td class="hide-on-small-only">Directory</td> <td class="hide-on-small-only">Directory</td>
<td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td> <td><span class="hide-on-med-and-up">Directory<br></span><strong>{{ source.directory }}</strong></td>
</tr> </tr>
<tr title="Filter text">
<td class="hide-on-small-only">Filter text</td>
<td><span class="hide-on-med-and-up">Filter text<br></span><strong>{{ source.filter_text }}</strong></td>
</tr>
<tr title="Media file name format to use for saving files"> <tr title="Media file name format to use for saving files">
<td class="hide-on-small-only">Media format</td> <td class="hide-on-small-only">Media format</td>
<td><span class="hide-on-med-and-up">Media format<br></span><strong>{{ source.media_format }}</strong></td> <td><span class="hide-on-med-and-up">Media format<br></span><strong>{{ source.media_format }}</strong></td>
@ -122,10 +118,6 @@
<tr title="Delete media that is no longer on this playlist?"> <tr title="Delete media that is no longer on this playlist?">
<td class="hide-on-small-only">Delete removed media</td> <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> <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> </tr>
{% if source.delete_old_media and source.days_to_keep > 0 %} {% 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"> <tr title="Days after which your media from this source will be locally deleted">

View File

@ -175,7 +175,6 @@ class FrontEndTestCase(TestCase):
'directory': 'testdirectory', 'directory': 'testdirectory',
'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
'download_cap': 0, 'download_cap': 0,
'filter_text':'.*',
'index_schedule': 3600, 'index_schedule': 3600,
'delete_old_media': False, 'delete_old_media': False,
'days_to_keep': 14, 'days_to_keep': 14,
@ -218,7 +217,6 @@ class FrontEndTestCase(TestCase):
'directory': 'testdirectory', 'directory': 'testdirectory',
'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
'download_cap': 0, 'download_cap': 0,
'filter_text':'.*',
'index_schedule': Source.IndexSchedule.EVERY_HOUR, 'index_schedule': Source.IndexSchedule.EVERY_HOUR,
'delete_old_media': False, 'delete_old_media': False,
'days_to_keep': 14, 'days_to_keep': 14,
@ -249,7 +247,6 @@ class FrontEndTestCase(TestCase):
'directory': 'testdirectory', 'directory': 'testdirectory',
'media_format': settings.MEDIA_FORMATSTR_DEFAULT, 'media_format': settings.MEDIA_FORMATSTR_DEFAULT,
'download_cap': 0, 'download_cap': 0,
'filter_text':'.*',
'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed 'index_schedule': Source.IndexSchedule.EVERY_2_HOURS, # changed
'delete_old_media': False, 'delete_old_media': False,
'days_to_keep': 14, 'days_to_keep': 14,
@ -661,8 +658,6 @@ class MediaTestCase(TestCase):
'<episodedetails>', '<episodedetails>',
' <title>no fancy stuff title</title>', ' <title>no fancy stuff title</title>',
' <showtitle>testname</showtitle>', ' <showtitle>testname</showtitle>',
' <season>2017</season>',
' <episode></episode>',
' <ratings>', ' <ratings>',
' <rating default="True" max="5" name="youtube">', ' <rating default="True" max="5" name="youtube">',
' <value>1.2345</value>', ' <value>1.2345</value>',
@ -1473,29 +1468,6 @@ class FormatMatchingTestCase(TestCase):
self.media.get_best_video_format() self.media.get_best_video_format()
self.media.get_best_audio_format() self.media.get_best_audio_format()
def test_is_regex_match(self):
self.media.metadata = all_test_metadata['boring']
expected_matches = {
('.*'): (True),
('no fancy stuff'): (True),
('No fancy stuff'): (False),
('(?i)No fancy stuff'): (True), #set case insensitive flag
('no'): (True),
('Foo'): (False),
('^(?!.*fancy).*$'): (False),
('^(?!.*funny).*$'): (True),
('(?=.*f.*)(?=.{0,2}|.{4,})'): (True),
('f{4,}'): (False),
('^[^A-Z]*$'): (True),
('^[^a-z]*$'): (False),
('^[^\\s]*$'): (False)
}
for params, expected in expected_matches.items():
self.source.filter_text = params
expected_match_result = expected
self.assertEqual(self.source.is_regex_match(self.media.title), expected_match_result)
class TasksTestCase(TestCase): class TasksTestCase(TestCase):
def setUp(self): def setUp(self):

View File

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

View File

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

View File

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

View File

@ -1,4 +1,3 @@
import os
from pathlib import Path from pathlib import Path
@ -7,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
DOWNLOADS_BASE_DIR = BASE_DIR DOWNLOADS_BASE_DIR = BASE_DIR
VERSION = '0.13.3' VERSION = '0.13.2'
SECRET_KEY = '' SECRET_KEY = ''
DEBUG = False DEBUG = False
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
@ -97,7 +96,7 @@ AUTH_PASSWORD_VALIDATORS = [
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = os.getenv('TZ', 'UTC') TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_L10N = True USE_L10N = True
USE_TZ = True USE_TZ = True