Compare commits
No commits in common. "main" and "v0.13.0" have entirely different histories.
|
@ -4,7 +4,6 @@ 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-11-29-14-19"
|
ARG FFMPEG_DATE="autobuild-2023-09-24-14-11"
|
||||||
ARG FFMPEG_VERSION="112875-g47e214245b"
|
ARG FFMPEG_VERSION="112171-g13a3e2a9b4"
|
||||||
|
|
||||||
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 "71cd08ed38c33ff2625dcca68d05efda090bdae455625d3bb1e4be4a53bf7c11" ;; \
|
||||||
"linux/arm64") echo "8f36e45d99d2367a5c0c220ee3164fa48f4f0cec35f78204ccced8dc303bfbdc" ;; \
|
"linux/arm64") echo "b6765d97f20cecef0121559ee26a2f0dfbac6aef49c48c71eb703271cb3f527b" ;; \
|
||||||
*) 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,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 ..
|
||||||
|
|
||||||
|
|
|
@ -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,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.
|
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -1,17 +0,0 @@
|
||||||
# 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'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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'),
|
|
||||||
),
|
|
||||||
]
|
|
|
@ -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,6 @@ 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')
|
|
||||||
)
|
|
||||||
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,
|
||||||
|
@ -525,7 +511,6 @@ class Source(models.Model):
|
||||||
'dd': now.strftime('%d'),
|
'dd': now.strftime('%d'),
|
||||||
'source': self.slugname,
|
'source': self.slugname,
|
||||||
'source_full': self.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',
|
||||||
|
@ -547,11 +532,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.
|
||||||
|
@ -1040,7 +1020,6 @@ class Media(models.Model):
|
||||||
'acodec': display_format['acodec'],
|
'acodec': display_format['acodec'],
|
||||||
'fps': display_format['fps'],
|
'fps': display_format['fps'],
|
||||||
'hdr': display_format['hdr'],
|
'hdr': display_format['hdr'],
|
||||||
'uploader': self.uploader,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -1237,6 +1216,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 +1225,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 +1249,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 +1356,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 +1373,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):
|
||||||
'''
|
'''
|
||||||
|
|
|
@ -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:
|
||||||
|
@ -177,7 +156,7 @@ def media_post_save(sender, instance, created, **kwargs):
|
||||||
verbose_name = _('Downloading metadata for "{}"')
|
verbose_name = _('Downloading metadata for "{}"')
|
||||||
download_media_metadata(
|
download_media_metadata(
|
||||||
str(instance.pk),
|
str(instance.pk),
|
||||||
priority=5,
|
priority=10,
|
||||||
verbose_name=verbose_name.format(instance.pk),
|
verbose_name=verbose_name.format(instance.pk),
|
||||||
remove_existing_tasks=True
|
remove_existing_tasks=True
|
||||||
)
|
)
|
||||||
|
@ -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)
|
||||||
|
|
|
@ -142,15 +142,6 @@ 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):
|
||||||
'''
|
'''
|
||||||
|
@ -195,9 +186,6 @@ 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)
|
||||||
|
@ -231,9 +219,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 +242,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):
|
||||||
|
|
|
@ -43,11 +43,6 @@
|
||||||
<td>Full source name</td>
|
<td>Full source name</td>
|
||||||
<td>My Source</td>
|
<td>My Source</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td>{uploader}</td>
|
|
||||||
<td>Uploader name</td>
|
|
||||||
<td>Some Channel Name</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>{title}</td>
|
<td>{title}</td>
|
||||||
<td>Lower case media title, max 80 chars</td>
|
<td>Lower case media title, max 80 chars</td>
|
||||||
|
|
|
@ -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 %}
|
||||||
|
|
|
@ -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 "also delete downloaded media" checkbox to also remove directory {{ source.directory_path }}
|
tick the "also delete downloaded media" 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>
|
||||||
|
|
|
@ -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>
|
||||||
|
@ -119,14 +115,6 @@
|
||||||
<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>
|
|
||||||
<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 %}
|
{% 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,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):
|
||||||
|
|
|
@ -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,13 +294,12 @@ 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',
|
'days_to_keep', 'source_resolution', 'source_vcodec', 'source_acodec',
|
||||||
'source_vcodec', 'source_acodec', 'prefer_60fps', 'prefer_hdr', 'fallback',
|
'prefer_60fps', 'prefer_hdr', 'fallback', 'copy_thumbnails', 'write_nfo',
|
||||||
'copy_thumbnails', 'write_nfo', 'write_json', 'embed_metadata', 'embed_thumbnail',
|
'write_json', 'embed_metadata', 'embed_thumbnail', 'enable_sponsorblock',
|
||||||
'enable_sponsorblock', 'sponsorblock_categories', 'write_subtitles',
|
'sponsorblock_categories', 'write_subtitles', 'auto_subtitles', 'sub_langs')
|
||||||
'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 '
|
||||||
|
@ -406,7 +403,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 +434,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 +652,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
|
||||||
|
|
|
@ -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])
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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.0'
|
||||||
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
|
||||||
|
|
Loading…
Reference in New Issue