Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa9c0ec8a | ||
|
|
e54a762a7b | ||
|
|
512b70adad | ||
|
|
6c21ff15ab | ||
|
|
adf26cb4e3 | ||
|
|
45c12561ba | ||
|
|
2d6f485a5d | ||
|
|
33b471175a | ||
|
|
7f4e8586b7 | ||
|
|
bab4b9b056 | ||
|
|
30c2127271 | ||
|
|
d1cb7ef76c | ||
|
|
1fd4f87c53 | ||
|
|
cf06f4cbc2 | ||
|
|
0523f481d2 | ||
|
|
aa4bd4ec26 | ||
|
|
96d9ee93ef | ||
|
|
8240c49d5c | ||
|
|
0c5e3d3818 | ||
|
|
22edd1bbda | ||
|
|
fea0bb191e | ||
|
|
0f65a4027a | ||
|
|
5cac374486 | ||
|
|
69efc9298d | ||
|
|
1be8dff769 | ||
|
|
350e544594 | ||
|
|
0542c734e5 | ||
|
|
42b337c408 | ||
|
|
2f82f8c599 |
1
.github/workflows/ci.yaml
vendored
1
.github/workflows/ci.yaml
vendored
@@ -4,6 +4,7 @@ env:
|
|||||||
IMAGE_NAME: tubesync
|
IMAGE_NAME: tubesync
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
|
|||||||
@@ -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-09-24-14-11"
|
ARG FFMPEG_DATE="autobuild-2023-11-29-14-19"
|
||||||
ARG FFMPEG_VERSION="112171-g13a3e2a9b4"
|
ARG FFMPEG_VERSION="112875-g47e214245b"
|
||||||
|
|
||||||
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 "71cd08ed38c33ff2625dcca68d05efda090bdae455625d3bb1e4be4a53bf7c11" ;; \
|
"linux/amd64") echo "36bac8c527bf390603416f749ab0dd860142b0a66f0865b67366062a9c286c8b" ;; \
|
||||||
"linux/arm64") echo "b6765d97f20cecef0121559ee26a2f0dfbac6aef49c48c71eb703271cb3f527b" ;; \
|
"linux/arm64") echo "8f36e45d99d2367a5c0c220ee3164fa48f4f0cec35f78204ccced8dc303bfbdc" ;; \
|
||||||
*) 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" ;; \
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -29,6 +29,10 @@ 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 ..
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ $ docker exec -i tubesync python3 /app/manage.py dumpdata > some-file.json
|
|||||||
Then change you database backend over, then use
|
Then change you database backend over, then use
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ cat some-file.json | docker exec -i tubesync python3 /app/manage.py loaddata --format=json -
|
$ cat some-file.json | docker exec -i tubesync python3 /app/manage.py loaddata - --format=json
|
||||||
```
|
```
|
||||||
|
|
||||||
As detailed in the Django documentation:
|
As detailed in the Django documentation:
|
||||||
@@ -78,3 +78,46 @@ 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!
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
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.DEBUG)
|
log.setLevel(logging_level)
|
||||||
ch = logging.StreamHandler()
|
ch = logging.StreamHandler()
|
||||||
ch.setLevel(logging.DEBUG)
|
ch.setLevel(logging_level)
|
||||||
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)
|
||||||
|
|||||||
@@ -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 %}">{{ 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 %}{% if only_skipped %}&only_skipped=yes{% endif %}">{{ i }}</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
17
tubesync/sync/migrations/0019_add_delete_removed_media.py
Normal file
17
tubesync/sync/migrations/0019_add_delete_removed_media.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by pac
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('sync', '0018_source_subtitles'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='source',
|
||||||
|
name='delete_removed_media',
|
||||||
|
field=models.BooleanField(default=False, help_text='Delete media that is no longer on this playlist', verbose_name='delete removed media'),
|
||||||
|
),
|
||||||
|
]
|
||||||
29
tubesync/sync/migrations/0020_auto_20231024_1825.py
Normal file
29
tubesync/sync/migrations/0020_auto_20231024_1825.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -287,6 +288,18 @@ 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'),
|
||||||
|
default=False,
|
||||||
|
help_text=_('Delete media that is no longer on this playlist')
|
||||||
|
)
|
||||||
source_resolution = models.CharField(
|
source_resolution = models.CharField(
|
||||||
_('source resolution'),
|
_('source resolution'),
|
||||||
max_length=8,
|
max_length=8,
|
||||||
@@ -510,7 +523,8 @@ class Source(models.Model):
|
|||||||
'mm': now.strftime('%m'),
|
'mm': now.strftime('%m'),
|
||||||
'dd': now.strftime('%d'),
|
'dd': now.strftime('%d'),
|
||||||
'source': self.slugname,
|
'source': self.slugname,
|
||||||
'source_full': self.source.name,
|
'source_full': self.name,
|
||||||
|
'uploader': 'Some Channel Name',
|
||||||
'title': 'some-media-title-name',
|
'title': 'some-media-title-name',
|
||||||
'title_full': 'Some Media Title Name',
|
'title_full': 'Some Media Title Name',
|
||||||
'key': 'SoMeUnIqUiD',
|
'key': 'SoMeUnIqUiD',
|
||||||
@@ -532,6 +546,11 @@ 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.
|
||||||
|
|||||||
@@ -96,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:
|
if not instance.downloaded and instance.metadata:
|
||||||
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:
|
||||||
@@ -117,11 +117,20 @@ 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:
|
||||||
# Media was published after the cap date but is set to be skipped
|
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.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')
|
||||||
@@ -130,10 +139,20 @@ 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
|
||||||
log.info(f'Media: {instance.source} / {instance} has a valid '
|
if filter_text:
|
||||||
f'publishing date, marking to be unskipped')
|
if instance.source.is_regex_match(instance.title):
|
||||||
instance.skip = False
|
log.info(f'Media: {instance.source} / {instance} has a valid '
|
||||||
cap_changed = True
|
f'publishing date and title filter, marking to be unskipped')
|
||||||
|
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:
|
||||||
|
|||||||
@@ -142,6 +142,15 @@ def cleanup_old_media():
|
|||||||
media.delete()
|
media.delete()
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_removed_media(source, videos):
|
||||||
|
media_objects = Media.objects.filter(source=source, downloaded=True)
|
||||||
|
for item in media_objects:
|
||||||
|
matching_source_item = [video['id'] for video in videos if video['id'] == item.key]
|
||||||
|
if not matching_source_item:
|
||||||
|
log.info(f'{item.title} is no longer in source, removing')
|
||||||
|
item.delete()
|
||||||
|
|
||||||
|
|
||||||
@background(schedule=0)
|
@background(schedule=0)
|
||||||
def index_source_task(source_id):
|
def index_source_task(source_id):
|
||||||
'''
|
'''
|
||||||
@@ -186,6 +195,9 @@ def index_source_task(source_id):
|
|||||||
cleanup_completed_tasks()
|
cleanup_completed_tasks()
|
||||||
# Tack on a cleanup of old media
|
# Tack on a cleanup of old media
|
||||||
cleanup_old_media()
|
cleanup_old_media()
|
||||||
|
if source.delete_removed_media:
|
||||||
|
log.info(f'Cleaning up media no longer in source {source}')
|
||||||
|
cleanup_removed_media(source, videos)
|
||||||
|
|
||||||
|
|
||||||
@background(schedule=0)
|
@background(schedule=0)
|
||||||
@@ -219,11 +231,9 @@ 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)
|
||||||
@@ -242,6 +252,11 @@ 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):
|
||||||
|
|||||||
@@ -64,5 +64,5 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped %}
|
{% include 'pagination.html' with pagination=sources.paginator filter=source.pk show_skipped=show_skipped only_skipped=only_skipped%}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -43,6 +43,10 @@
|
|||||||
<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>
|
||||||
@@ -115,6 +119,10 @@
|
|||||||
<td class="hide-on-small-only">Write JSON?</td>
|
<td class="hide-on-small-only">Write JSON?</td>
|
||||||
<td><span class="hide-on-med-and-up">Write JSON?<br></span><strong>{% if source.write_json %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
<td><span class="hide-on-med-and-up">Write JSON?<br></span><strong>{% if source.write_json %}<i class="fas fa-check"></i>{% else %}<i class="fas fa-times"></i>{% endif %}</strong></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<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>
|
||||||
{% 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">
|
||||||
<td class="hide-on-small-only">Delete old media</td>
|
<td class="hide-on-small-only">Delete old media</td>
|
||||||
|
|||||||
@@ -175,6 +175,7 @@ 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,
|
||||||
@@ -217,6 +218,7 @@ 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,
|
||||||
@@ -247,6 +249,7 @@ 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,
|
||||||
@@ -1468,6 +1471,29 @@ 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):
|
||||||
|
|||||||
@@ -294,12 +294,13 @@ class ValidateSourceView(FormView):
|
|||||||
|
|
||||||
class EditSourceMixin:
|
class EditSourceMixin:
|
||||||
model = Source
|
model = Source
|
||||||
fields = ('source_type', 'key', 'name', 'directory', 'media_format',
|
fields = ('source_type', 'key', 'name', 'directory', 'filter_text', 'media_format',
|
||||||
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
'index_schedule', 'download_media', 'download_cap', 'delete_old_media',
|
||||||
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
'delete_removed_media', 'days_to_keep', 'source_resolution', 'source_vcodec',
|
||||||
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo',
|
'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails',
|
||||||
'write_json', 'embed_metadata', 'embed_thumbnail', 'enable_sponsorblock',
|
'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
|
||||||
'sponsorblock_categories', 'write_subtitles', 'auto_subtitles', 'sub_langs')
|
'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles',
|
||||||
|
'auto_subtitles', 'sub_langs')
|
||||||
errors = {
|
errors = {
|
||||||
'invalid_media_format': _('Invalid media format, the media format contains '
|
'invalid_media_format': _('Invalid media format, the media format contains '
|
||||||
'errors or is empty. Check the table at the end of '
|
'errors or is empty. Check the table at the end of '
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ CONFIG_BASE_DIR = BASE_DIR
|
|||||||
DOWNLOADS_BASE_DIR = BASE_DIR
|
DOWNLOADS_BASE_DIR = BASE_DIR
|
||||||
|
|
||||||
|
|
||||||
VERSION = '0.13.1'
|
VERSION = '0.13.3'
|
||||||
SECRET_KEY = ''
|
SECRET_KEY = ''
|
||||||
DEBUG = False
|
DEBUG = False
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = []
|
||||||
|
|||||||
Reference in New Issue
Block a user