A Channel can be tagged with a single Tag. The Channel can be moved into a Tag Directory according to the config.

This commit is contained in:
Roland 2024-08-06 18:13:21 -06:00
parent 2b8ef79561
commit 45167e4f32
No known key found for this signature in database
GPG key ID: 1BA620CE6C9A4680
39 changed files with 691 additions and 165 deletions

View file

@ -0,0 +1,33 @@
"""Channel tag
Revision ID: d4e446637f18
Revises: 2bfb3b50b178
Create Date: 2024-08-03 17:49:17.133086
"""
import os
from alembic import op
from sqlalchemy.orm import Session
# revision identifiers, used by Alembic.
revision = 'd4e446637f18'
down_revision = '2bfb3b50b178'
branch_labels = None
depends_on = None
DOCKERIZED = True if os.environ.get('DOCKER', '').lower().startswith('t') else False
def upgrade():
bind = op.get_bind()
session = Session(bind=bind)
session.execute('ALTER TABLE channel ADD COLUMN tag_id INTEGER REFERENCES tag(id)')
def downgrade():
bind = op.get_bind()
session = Session(bind=bind)
session.execute('ALTER TABLE channel DROP COLUMN IF EXISTS tag_id')

View file

@ -131,8 +131,8 @@ export function useTags() {
</Label.Group>
}
const SingleTag = ({name}) => {
return <Label.Group tag><NameToTagLabel name={name}/></Label.Group>
const SingleTag = ({name, ...props}) => {
return <Label.Group tag {...props}><NameToTagLabel name={name}/></Label.Group>
}
useEffect(() => {

View file

@ -112,6 +112,32 @@ export async function getChannel(id) {
return (await response.json())['channel'];
}
export async function tagChannel(channelId, tagName, directory) {
const body = {
tag_name: tagName,
}
if (directory) {
body['directory'] = directory;
}
const response = await apiPost(`${VIDEOS_API}/channels/${channelId}/tag`, body);
if (!response.ok) {
toast({
type: 'error',
title: 'Unexpected server response',
description: 'Failed to tag channel. See server logs.',
time: 5000,
});
}
}
export async function tagChannelInfo(channelId, tagName) {
const body = {tag_name: tagName}
const response = await apiPost(`${VIDEOS_API}/channels/${channelId}/tag_info`, body);
if (response.ok) {
return (await response.json()).videos_destination;
}
}
export async function searchVideos(offset, limit, channelId, searchStr, order_by, tagNames, headline) {
// Build a search query to retrieve a list of videos from the API
offset = parseInt(offset || 0);

View file

@ -1,11 +1,13 @@
import React, {useState} from "react";
import {Dimmer, Dropdown, Grid, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react";
import {Dimmer, Dropdown, Grid, Label, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react";
import {
createChannel,
createChannelDownload,
deleteChannel,
deleteDownload,
refreshChannel,
tagChannel,
tagChannelInfo,
updateChannel,
updateChannelDownload
} from "../api";
@ -39,7 +41,9 @@ import {
Header,
Loader,
Modal,
ModalActions,
ModalContent,
ModalHeader,
Segment,
Statistic
} from "./Theme";
@ -47,7 +51,7 @@ import {Media, ThemeContext} from "../contexts/contexts";
import {SortableTable} from "./SortableTable";
import {toast} from "react-semantic-toasts-2";
import {RecurringDownloadsTable} from "./admin/Downloads";
import {TagsSelector} from "../Tags";
import {TagsContext, TagsSelector} from "../Tags";
function ChannelStatistics({statistics}) {
@ -85,22 +89,31 @@ function ChannelPage({create, header}) {
const [messageHeader, setMessageHeader] = useState();
const [messageContent, setMessageContent] = useState();
const [downloadModalOpen, setDownloadModalOpen] = useState(false);
const [rssDownloadOpen, setRssDownloadOpen] = useState(false);
const navigate = useNavigate();
const {channelId} = useParams();
const {channel, changeValue, fetchChannel} = useChannel(channelId);
const {SingleTag} = React.useContext(TagsContext);
let title;
if (channel && channel.name) {
title = channel.name;
}
useTitle(title);
const [tagModalOpen, setTagModalOpen] = useState(false);
const [newTagName, setNewTagName] = useState(channel ? channel.tag_name : null);
const [moveToTagDirectory, setMoveToTagDirectory] = useState(true);
const [newTagDirectory, setNewTagDirectory] = useState('');
if (!channel) {
useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`);
React.useEffect(() => {
console.debug(channel);
if (!_.isEmpty(channel)) {
setNewTagName(channel.tag_name);
}
}, [JSON.stringify(channel)]);
if (_.isEmpty(channel)) {
return <Loader active/>;
}
const handleCheckbox = (e, {name, checked}) => {
if (e) {
e.preventDefault();
@ -247,6 +260,64 @@ function ChannelPage({create, header}) {
setDownloadModalOpen(false);
}
const handleTagModalOpen = (e) => {
if (e) {
e.preventDefault();
}
setTagModalOpen(true);
}
const handleTagChannel = async () => {
try {
await tagChannel(channelId, newTagName, moveToTagDirectory ? newTagDirectory : null);
setTagModalOpen(false);
} catch (e) {
console.error('Failed to tag channel', e);
} finally {
await fetchChannel();
}
}
const handleTagSelect = async (newTagName_) => {
setNewTagName(newTagName_);
try {
const videosDestination = await tagChannelInfo(channelId, newTagName_);
setNewTagDirectory(videosDestination);
} catch (e) {
console.error('Failed to tag channel', e);
}
}
const tagModal = <Modal
open={tagModalOpen}
onClose={() => setTagModalOpen(false)}
closeIcon
>
<ModalHeader>{channel.tag_name ? 'Modify Tag' : 'Add Tag'}</ModalHeader>
<ModalContent>
<TagsSelector
limit={1}
selectedTagNames={newTagName ? [newTagName] : []}
onAdd={handleTagSelect}
onRemove={() => handleTagSelect(null)}
/>
<br/>
<Toggle
label='Move to directory: '
checked={moveToTagDirectory}
onChange={setMoveToTagDirectory}
/>
{newTagDirectory && <Label size='large'>{newTagDirectory}</Label>}
</ModalContent>
<ModalActions>
<Button onClick={() => setTagModalOpen(false)}>Cancel</Button>
<Button
color='violet'
onClick={handleTagChannel}
>Save</Button>
</ModalActions>
</Modal>;
return <>
<BackButton/>
{!create &&
@ -324,6 +395,8 @@ function ChannelPage({create, header}) {
</FormField>
</FormGroup>
{channel.tag_name && <SingleTag name={channel.tag_name}/>}
<Message error
header={messageHeader}
content={messageContent}
@ -356,6 +429,12 @@ function ChannelPage({create, header}) {
>Refresh</APIButton>
</>
}
<Button
size='small'
onClick={handleTagModalOpen}
color='green'
>Tag</Button>
{tagModal}
</Grid.Column>
<Grid.Column>
<APIButton
@ -587,6 +666,8 @@ export function ChannelNewPage(props) {
}
function ChannelRow({channel}) {
const {SingleTag} = React.useContext(TagsContext);
const videosTo = `/videos/channel/${channel.id}/video`;
const {inverted} = React.useContext(ThemeContext);
@ -597,6 +678,9 @@ function ChannelRow({channel}) {
<TableCell>
<Link to={videosTo}>{channel.name}</Link>
</TableCell>
<TableCell>
{channel.tag_name ? <SingleTag name={channel.tag_name}/> : null}
</TableCell>
<TableCell>
{channel.video_count}
</TableCell>
@ -613,6 +697,8 @@ function ChannelRow({channel}) {
}
function MobileChannelRow({channel}) {
const {SingleTag} = React.useContext(TagsContext);
const editTo = `/videos/channel/${channel.id}/edit`;
const videosTo = `/videos/channel/${channel.id}/video`;
return <TableRow verticalAlign='top'>
@ -621,6 +707,7 @@ function MobileChannelRow({channel}) {
<h3>
{channel.name}
</h3>
{channel.tag_name ? <SingleTag name={channel.tag_name}/> : null}
</Link>
<p>
Videos: {channel.video_count}
@ -669,6 +756,7 @@ export function ChannelsPage() {
const headers = [
{key: 'name', text: 'Name', sortBy: 'name', width: 8},
{key: 'tag', text: 'Tag', sortBy: 'tag', width: 2},
{key: 'video_count', text: 'Videos', sortBy: 'video_count', width: 2},
{key: 'download_frequency', text: 'Download Frequency', sortBy: 'minimum_frequency', width: 2},
{key: 'size', text: 'Size', sortBy: 'size', width: 2},

View file

@ -734,6 +734,10 @@ export const useDirectories = (defaultDirectory) => {
fetchDirectories();
}, [directory, defaultDirectory]);
useEffect(() => {
setDirectory(defaultDirectory);
}, [defaultDirectory])
return {directory, directories, setDirectory, exists, isDir, isFile};
}

View file

@ -322,9 +322,9 @@ async def perpetual_have_internet_worker():
# Check hourly once we have internet.
await asyncio.sleep(float(Seconds.hour))
else:
# Check every minute until the internet is back.
# Check more often until the internet is back.
flags.have_internet.clear()
await asyncio.sleep(float(Seconds.minute))
await asyncio.sleep(10)
except Exception as e:
logger.error('Failed to check if internet is up', exc_info=e)

View file

@ -48,7 +48,10 @@ class ArchiveFiles:
def get_archive_directory() -> pathlib.Path:
archive_directory = get_media_directory() / get_wrolpi_config().archive_directory
archive_destination = get_wrolpi_config().archive_destination
variables = dict(domain='', year='', month='', day='')
archive_destination = archive_destination % variables
archive_directory = get_media_directory() / archive_destination
return archive_directory

View file

@ -9,7 +9,8 @@ from sqlalchemy.orm import relationship, Session, validates
from sqlalchemy.orm.collections import InstrumentedList
from wrolpi import dates
from wrolpi.common import ModelHelper, Base, logger, get_title_from_html
from wrolpi.common import ModelHelper, Base, logger, get_title_from_html, get_wrolpi_config, get_media_directory
from wrolpi.dates import now
from wrolpi.db import optional_session
from wrolpi.errors import UnknownArchive
from wrolpi.files.models import FileGroup
@ -342,3 +343,21 @@ class Domain(Base, ModelHelper):
if len(value.split('.')) < 2:
raise ValueError(f'Domain must contain at least one "." domain={repr(value)}')
return value
@property
def download_directory(self) -> pathlib.Path:
archive_destination = get_wrolpi_config().archive_destination
now_ = now()
variables = dict(
domain=self.domain,
year=now_.year,
month=now_.month,
day=now_.day,
)
archive_destination = archive_destination % variables
archive_destination = pathlib.Path(archive_destination.lstrip('/'))
if not archive_destination.is_absolute():
archive_destination = get_media_directory() / archive_destination
return archive_destination

View file

@ -820,12 +820,13 @@ def test_get_url_from_singlefile():
assert lib.get_url_from_singlefile(SINGLEFILE_EXAMPLE_1) == 'https://www.example.com'
def test_get_custom_archive_directory(test_directory, test_config):
@pytest.mark.asyncio
async def test_get_custom_archive_directory(test_async_client, test_directory, test_config):
"""Custom directory can be used for archive directory."""
# Default location.
assert lib.get_archive_directory() == (test_directory / 'archive')
get_wrolpi_config().archive_directory = 'custom/archives'
get_wrolpi_config().archive_destination = 'custom/archives'
assert lib.get_archive_directory() == (test_directory / 'custom/archives')
assert lib.get_domain_directory('https://example.com') == (test_directory / 'custom/archives/example.com')

View file

@ -0,0 +1,27 @@
from datetime import datetime
import pytest
from modules.archive import Domain
from wrolpi.common import get_wrolpi_config
@pytest.mark.asyncio
async def test_archive_download_destination(test_async_client, test_session, test_directory, archive_factory, fake_now):
fake_now(datetime(2000, 1, 2))
wrolpi_config = get_wrolpi_config()
archive_factory(domain='wrolpi.org')
domain = test_session.query(Domain).one()
# Test the default download directory.
assert str(domain.download_directory) == str(test_directory / 'archive/wrolpi.org')
# Year of download is supported.
wrolpi_config.archive_destination = 'archives/%(domain)s/%(year)s'
assert str(domain.download_directory) == str(test_directory / 'archives/wrolpi.org/2000')
# More download date is supported.
wrolpi_config.archive_destination = 'archive/%(domain)s/%(year)s/%(month)s/%(day)s'
assert str(domain.download_directory) == str(test_directory / 'archive/wrolpi.org/2000/1/2')

View file

@ -19,7 +19,7 @@ logger = logger.getChild(__name__)
def get_map_directory() -> Path:
map_directory = get_media_directory() / get_wrolpi_config().map_directory
map_directory = get_media_directory() / get_wrolpi_config().map_destination
if not map_directory.is_dir():
map_directory.mkdir(parents=True)
return map_directory

View file

@ -107,7 +107,7 @@ def test_get_custom_map_directory(test_directory, test_config):
# Default location.
assert lib.get_map_directory() == (test_directory / 'map')
get_wrolpi_config().map_directory = 'custom/deep/map/directory'
get_wrolpi_config().map_destination = 'custom/deep/map/directory'
assert lib.get_map_directory() == (test_directory / 'custom/deep/map/directory')
assert (test_directory / 'custom/deep/map/directory').is_dir()

View file

@ -17,7 +17,7 @@ from wrolpi.vars import PYTEST
from . import lib
from .. import schema
from ..errors import UnknownChannel
from ..lib import save_channels_config
from ..lib import save_channels_config, format_videos_destination
from ..models import Channel
channel_bp = Blueprint('Channel', url_prefix='/api/videos/channels')
@ -136,3 +136,41 @@ async def channel_download(_: Request, channel_id: int, body: schema.ChannelDown
# Warn the user that downloads are disabled.
Events.send_downloads_disabled('Channel download created. But downloads are disabled.')
return response.empty()
@channel_bp.post('/<channel_id:int>/tag_info')
@openapi.definition(
description='Get Channel Tag Info',
body=schema.ChannelTagInfoRequest,
)
@validate(schema.ChannelTagInfoRequest)
@run_after(save_channels_config)
@wrol_mode_check
async def channel_tag_info(_: Request, channel_id: int, body: schema.ChannelTagInfoRequest):
from wrolpi.tags import Tag
channel_name = Channel.find_by_id(channel_id).name
tag_name = Tag.find_by_name(body.tag_name).name if body.tag_name else None
videos_destination = format_videos_destination(channel_name, tag_name)
ret = dict(videos_destination=videos_destination)
return json_response(ret)
@channel_bp.post('/<channel_id:int>/tag')
@openapi.definition(
description='Tag Channel',
body=schema.ChannelTagRequest,
)
@openapi.response(HTTPStatus.NO_CONTENT)
@openapi.response(HTTPStatus.BAD_REQUEST, JSONErrorResponse)
@validate(schema.ChannelTagRequest)
@run_after(save_channels_config)
@wrol_mode_check
async def channel_tag(_: Request, channel_id: int, body: schema.ChannelTagRequest):
directory = None
if body.directory:
directory = pathlib.Path(body.directory)
directory = get_media_directory() / directory
await lib.tag_channel(body.tag_name, directory, channel_id)
return response.raw('', HTTPStatus.NO_CONTENT)

View file

@ -1,9 +1,9 @@
import pathlib
from pathlib import Path
from typing import List, Dict, Union
from sqlalchemy import or_, func, desc, asc
from sqlalchemy.orm import Session
from sqlalchemy.orm.exc import NoResultFound
from wrolpi.common import run_after, logger, \
get_media_directory
@ -12,7 +12,7 @@ from wrolpi.downloader import save_downloads_config, download_manager
from wrolpi.errors import UnknownDirectory, APIError, ValidationError
from .. import schema
from ..common import check_for_channel_conflicts
from ..errors import UnknownChannel
from ..errors import UnknownChannel, ChannelDirectoryConflict
from ..lib import save_channels_config
from ..models import Channel, Video
@ -28,7 +28,7 @@ async def get_minimal_channels() -> List[dict]:
# one that will consume the most resources.
stmt = '''
SELECT
c.id, name, directory, c.url,
c.id, c.name AS "name", directory, c.url, t.name AS "tag_name",
COUNT(v.id) as video_count,
SUM(fg.size)::BIGINT AS size,
(
@ -40,7 +40,8 @@ async def get_minimal_channels() -> List[dict]:
channel AS c
LEFT JOIN video v on c.id = v.channel_id
LEFT JOIN file_group fg on fg.id = v.file_group_id
GROUP BY 1, 2, 3, 4
LEFT JOIN tag t ON t.id = c.tag_id
GROUP BY 1, 2, 3, 4, 5
'''
curs.execute(stmt)
channels = sorted([dict(i) for i in curs.fetchall()], key=lambda i: i['name'].lower())
@ -115,10 +116,7 @@ def get_channel(session: Session, *, channel_id: int = None, source_id: str = No
@optional_session
def update_channel(session: Session, *, data: schema.ChannelPutRequest, channel_id: int) -> Channel:
"""Update a Channel's DB record"""
try:
channel: Channel = session.query(Channel).filter_by(id=channel_id).one()
except NoResultFound:
raise UnknownChannel()
channel = Channel.find_by_id(channel_id, session)
# Only update directory if it was empty
if data.directory and not channel.directory:
@ -237,8 +235,9 @@ async def update_channel_download(channel_id: int, download_id: int, url: str, f
Channel.find_by_id(channel_id, session=session)
download = download_manager.get_download(session, id_=download_id)
download.url = url
download.frequency = download.frequency or frequency
download.frequency = frequency
download.settings = settings
session.commit()
download_manager.remove_from_skip_list(url)
@ -246,3 +245,38 @@ async def update_channel_download(channel_id: int, download_id: int, url: str, f
await save_downloads_config()
return download
@optional_session
async def tag_channel(tag_name: str, directory: pathlib.Path | None, channel_id: int, session: Session = None):
"""Add a Tag to a Channel, or remove a Tag from a Channel if no `tag_name` is provided.
Move the Channel to the new directory, if provided."""
from wrolpi.tags import Tag
channel = Channel.find_by_id(channel_id, session=session)
if tag_name:
tag = Tag.find_by_name(tag_name, session=session)
channel.tag_id = tag.id
channel.tag = tag
else:
channel.tag = channel.tag_id = None
session.flush([channel, ])
# Do not move Channel files into a directory with files.
if directory and directory.is_dir() and next(directory.iterdir(), None):
raise ChannelDirectoryConflict('Channel directory already exists and is not empty')
# Only move Channel when requested.
if directory:
# Move to newly defined directory only if necessary.
logger.info(f'Tagging {channel} with {tag_name} moving to {directory} from {channel.directory}')
directory.mkdir(parents=True, exist_ok=True)
if channel.directory != directory:
await channel.move(directory, session)
else:
logger.info(f'Tagging {channel} with {tag_name}')
session.commit()

View file

@ -8,8 +8,11 @@ from http import HTTPStatus
import mock
import pytest
from modules.videos.common import get_videos_directory
from modules.videos.downloader import ChannelDownloader
from modules.videos.models import Channel
from modules.videos.lib import save_channels_config, get_channels_config
from modules.videos.models import Channel, Video
from wrolpi.common import get_relative_to_media_directory, walk
from wrolpi.dates import now
from wrolpi.db import get_db_session
from wrolpi.downloader import download_manager, Download, DownloadResult
@ -412,7 +415,7 @@ def test_get_channel_videos_pagination(test_session, simple_channel, video_facto
@pytest.mark.asyncio
async def test_create_channel_download(test_async_client, test_session, simple_channel, tag_factory):
async def test_create_channel_download_api(test_async_client, test_session, simple_channel, tag_factory):
tag = tag_factory()
# Create Download.
@ -452,3 +455,93 @@ async def test_create_channel_download(test_async_client, test_session, simple_c
assert response.status_code == HTTPStatus.NO_CONTENT
assert test_session.query(Download).count() == 1
assert test_session.query(Channel).count() == 1
@pytest.mark.asyncio
async def test_tag_channel(test_async_client, test_session, test_directory, channel_factory, tag_factory, video_factory,
test_channels_config, test_download_manager, test_downloader):
"""A single Tag can be applied to a Channel."""
# Create channel directory in the usual videos directory.
videos_directory = get_videos_directory()
channel = channel_factory(name='Channel Name', download_frequency=120)
channel_directory = channel.directory
tag = tag_factory('Tag Name')
v1, v2 = video_factory(title='video1', channel_id=channel.id), video_factory(title='video2', channel_id=channel.id)
# Create recurring download which uses the Channel's directory.
download = download_manager.recurring_download('https://example.com/1', 60, test_downloader.name,
settings={
'destination': str(test_directory / 'videos/Channel Name')})
test_session.commit()
save_channels_config()
assert get_channels_config().channels[0]['directory'] == str(test_directory / 'videos/Channel Name')
# Channel download downloads into the Channel's directory.
assert channel.downloads[0].settings['destination'] == str(test_directory / 'videos/Channel Name')
# Make extra file in the Channel's directory, it should be moved.
(channel_directory / 'extra file.txt').write_text('extra file contents')
# Channel directory is in the Videos directory.
assert channel.directory == channel_directory
# Videos are in the Channel's Directory.
assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Channel Name/video1.mp4'
assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Channel Name/video2.mp4'
video_files = [i for i in walk(videos_directory) if i.is_file()]
assert len(video_files) == 3
body = dict(tag_name=tag.name, directory='videos/Tag Name/Channel Name')
request, response = await test_async_client.post(f'/api/videos/channels/{channel.id}/tag', json=body)
assert response.status_code == HTTPStatus.NO_CONTENT
channel = test_session.query(Channel).one()
assert channel.tag_name == tag.name
# Channel was moved to Tag's directory, old directory was removed
new_channel_directory = test_directory / 'videos/Tag Name/Channel Name'
assert channel.directory == new_channel_directory, 'Channel directory should have been changed.'
assert new_channel_directory.is_dir(), 'Channel should have been moved.'
assert next(new_channel_directory.iterdir(), None), 'Channel videos should have been moved.'
assert not channel_directory.exists(), 'Old Channel directory should have been deleted.'
# Channel download goes into the Channel's directory.
d1, d2 = test_session.query(Download).all()
assert d1.settings['destination'] == str(test_directory / 'videos/Tag Name/Channel Name'), \
f'{d1} was not moved'
assert d2.settings['destination'] == str(test_directory / 'videos/Tag Name/Channel Name'), \
f'{d2} was not moved'
# Videos were moved
v1, v2 = test_session.query(Video).order_by(Video.id).all()
assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Tag Name/Channel Name/video1.mp4'
assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Tag Name/Channel Name/video2.mp4'
# Extra file was also moved.
assert (new_channel_directory / 'extra file.txt').read_text() == 'extra file contents'
# No new files were created.
assert len([i for i in walk(videos_directory) if i.is_file()]) == 3
assert get_channels_config().channels[0]['directory'] == str(test_directory / 'videos/Tag Name/Channel Name')
# Remove tag, Channel/Videos should be moved back.
body = dict(tag_name=None, directory='videos/Channel Name')
request, response = await test_async_client.post(f'/api/videos/channels/{channel.id}/tag', json=body)
assert response.status_code == HTTPStatus.NO_CONTENT, response.content.decode()
channel = test_session.query(Channel).one()
assert channel.tag_name is None
assert str(channel.directory) == str(test_directory / 'videos/Channel Name')
v1, v2 = test_session.query(Video).order_by(Video.id).all()
assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Channel Name/video1.mp4'
assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Channel Name/video2.mp4'
assert not (test_directory / 'videos/Tag Name/Channel Name').exists()
# Downloads are moved back.
d1, d2 = test_session.query(Download).all()
assert d1.settings['destination'] == str(test_directory / 'videos/Channel Name'), f'{d1} was not moved'
assert d2.settings['destination'] == str(test_directory / 'videos/Channel Name'), f'{d2} was not moved'
# Channel can be Tagged, but not move directories.
body = dict(tag_name=tag.name)
request, response = await test_async_client.post(f'/api/videos/channels/{channel.id}/tag', json=body)
assert response.status_code == HTTPStatus.NO_CONTENT, response.content.decode()
channel = test_session.query(Channel).one()
assert channel.tag_name == 'Tag Name'
assert str(channel.directory) == str(test_directory / 'videos/Channel Name')
assert str(get_relative_to_media_directory(v1.video_path)) == 'videos/Channel Name/video1.mp4'
assert str(get_relative_to_media_directory(v2.video_path)) == 'videos/Channel Name/video2.mp4'
# Downloads were not changed.
d1, d2 = test_session.query(Download).all()
assert d1.settings['destination'] == str(test_directory / 'videos/Channel Name'), f'{d1} was not moved'
assert d2.settings['destination'] == str(test_directory / 'videos/Channel Name'), f'{d2} was not moved'

View file

@ -3,10 +3,9 @@ from http import HTTPStatus
import pytest
from modules.videos.downloader import VideoDownloader
from modules.videos.models import Channel
from modules.videos.models import Video
from wrolpi.downloader import Download, DownloadFrequency, download_manager, RSSDownloader
from wrolpi.downloader import Download, DownloadFrequency
def test_delete_channel_no_url(test_session, test_client, channel_factory):
@ -102,17 +101,3 @@ async def test_channel_download_relationships(test_session, download_channel):
# Delete the Channel, and any relationships.
download_channel.delete_with_videos()
assert test_session.query(Download).count() == 0
@pytest.mark.asyncio()
async def test_create_channel_download(test_session, simple_channel):
"""A Download will be related to a Channel if a Download is created in the Channel's directory"""
test_session.flush()
settings = dict(destination=str(simple_channel.directory))
download_manager.recurring_download('https://example.com/2', 1, RSSDownloader.name, test_session,
sub_downloader_name=VideoDownloader.name, settings=settings)
download = test_session.query(Download).one()
assert download.settings['destination'] == str(simple_channel.directory)
download = test_session.query(Download).one()
assert download.channel == simple_channel

View file

@ -35,8 +35,9 @@ class ConfigError(Exception):
def get_videos_directory() -> pathlib.Path:
"""Get the "videos" directory in the media directory. Make it if it does not exist."""
config = get_wrolpi_config()
directory = get_media_directory() / config.videos_directory
from modules.videos.lib import format_videos_destination
videos_destination = format_videos_destination()
directory = get_media_directory() / videos_destination
if not directory.is_dir():
directory.mkdir(parents=True)
return directory

View file

@ -11,12 +11,11 @@ import pytest
from PIL import Image
from modules.videos.downloader import VideoDownloader, ChannelDownloader
from modules.videos.lib import set_test_channels_config, set_test_downloader_config
from modules.videos.lib import set_test_channels_config, set_test_downloader_config, format_videos_destination
from modules.videos.models import Channel, Video
from wrolpi.api_utils import api_app
from wrolpi.downloader import DownloadFrequency, DownloadManager
from wrolpi.files.models import FileGroup
from wrolpi.tags import Tag
from wrolpi.vars import PROJECT_DIR
@ -36,25 +35,25 @@ def simple_channel(test_session, test_directory) -> Channel:
@pytest.fixture
def channel_factory(test_session, test_directory):
"""Create a random Channel with a directory, and Download."""
from wrolpi.tags import Tag
def factory(source_id: str = None, download_frequency: DownloadFrequency = None, url: str = None, name: str = None,
directory: pathlib.Path = None):
directory: pathlib.Path = None, tag_name: str = None):
name = name or str(uuid4())
directory = directory or test_directory / name
if not directory.is_dir():
directory.mkdir()
tag = Tag.get_by_name(tag_name) if tag_name else None
channel = Channel(
directory=directory, # noqa
name=name,
url=url or f'https://example.com/{name}',
source_id=source_id,
tag=tag,
)
channel.directory = directory or format_videos_destination(name, tag, channel.url)
channel.directory.mkdir(exist_ok=True, parents=True)
test_session.add(channel)
test_session.flush([channel])
assert channel.id and channel.url
if download_frequency:
download = channel.get_or_create_download(channel.url, 60)
download.frequency = download_frequency
download = channel.get_or_create_download(channel.url, download_frequency)
assert download.url == channel.url
assert channel.downloads
test_session.commit()
@ -106,8 +105,9 @@ def video_factory(test_session, test_directory):
path = pathlib.Path(with_video_file) if not isinstance(with_video_file, pathlib.Path) else with_video_file
elif channel_id:
# Put the video in its Channel's directory.
channel = test_session.query(Channel).filter_by(id=channel_id).one()
channel = Channel.find_by_id(channel_id)
path = (channel.directory or test_directory) / f'{title}.mp4'
path.parent.mkdir(exist_ok=True, parents=True)
else:
# Put any video not in a Channel and without a specified file in the NO CHANNEL directory.
(test_directory / 'videos/NO CHANNEL').mkdir(exist_ok=True, parents=True)

View file

@ -264,7 +264,7 @@ class VideoDownloader(Downloader, ABC):
channel_source_id = download.info_json.get('channel_id') or download.info_json.get('uploader_id')
channel_url = download.info_json.get('channel_url') or download.info_json.get('uploader_url')
channel = None
if channel_name or channel_source_id:
if channel_name or channel_source_id or channel_url:
# Try to find the channel via info_json from yt-dlp.
try:
channel = get_or_create_channel(source_id=channel_source_id, url=channel_url, name=channel_name)
@ -278,7 +278,7 @@ class VideoDownloader(Downloader, ABC):
settings = download.settings or dict()
destination = settings.get('destination')
if not channel and destination:
# Destination may override the real Channel.
# Destination may find Channel if not already found.
try:
channel = get_channel(directory=destination, return_dict=False)
found_channel = 'download_settings_directory'
@ -290,7 +290,7 @@ class VideoDownloader(Downloader, ABC):
local_channel_id = settings.get('channel_id')
channel_url = settings.get('channel_url')
if not channel and (local_channel_id or channel_url):
# Could not find channel via yt-dlp info_json, use info from ChannelDownloader if it created this Download.
# Could not find Channel via yt-dlp info_json, use info from ChannelDownloader if it created this Download.
logger.info(f'Using download.settings to find channel')
try:
channel = get_channel(channel_id=local_channel_id, url=channel_url, return_dict=False)
@ -320,11 +320,12 @@ class VideoDownloader(Downloader, ABC):
logger.debug(f'Downloading {url} to destination from settings')
elif channel:
out_dir = channel_directory
logger.debug(f'Downloading {url} to channel directory')
logger.debug(f'Downloading {url} to channel directory: {channel_directory}')
else:
# Download to the default directory if this video has no channel.
out_dir = get_no_channel_directory()
logger.debug(f'Downloading {url} to default directory')
out_dir.mkdir(exist_ok=True, parents=True)
logs = None # noqa

View file

@ -15,6 +15,12 @@ class UnknownChannel(APIError):
status_code = HTTPStatus.NOT_FOUND
class InvalidChannel(APIError):
code = 'INVALID_CHANNEL'
message = 'The channel is not supported'
status_code = HTTPStatus.BAD_REQUEST
class ChannelNameConflict(APIError):
code = 'CHANNEL_NAME_CONFLICT'
message = 'The channel name is already taken.'

View file

@ -3,27 +3,30 @@ import dataclasses
import html
import pathlib
import re
from typing import Tuple, Optional, Generator
from typing import Generator
from typing import Tuple, Optional
import pytz
from sqlalchemy import or_
from sqlalchemy.orm import Session
from yt_dlp import YoutubeDL
from modules.videos.models import Video
from wrolpi import before_startup, dates, flags
from wrolpi.captions import extract_captions
from wrolpi.common import ConfigFile, get_media_directory, register_refresh_cleanup, limit_concurrent
from wrolpi.common import ConfigFile, limit_concurrent, get_wrolpi_config, extract_domain, \
escape_file_name, get_media_directory
from wrolpi.dates import Seconds, from_timestamp
from wrolpi.db import get_db_curs, get_db_session, optional_session
from wrolpi.downloader import Download
from wrolpi.errors import UnknownDirectory
from wrolpi.files.lib import split_path_stem_and_suffix
from wrolpi.vars import PYTEST, YTDLP_CACHE_DIR
from wrolpi.vars import YTDLP_CACHE_DIR, PYTEST
from .common import is_valid_poster, convert_image, \
generate_video_poster, logger, REQUIRED_OPTIONS, ConfigError, \
get_video_duration
from .errors import UnknownChannel
from .models import Channel, Video
from .models import Channel
logger = logger.getChild(__name__)
@ -377,7 +380,6 @@ channel_import_logger = logger.getChild('channel_import')
@before_startup
@register_refresh_cleanup
@limit_concurrent(1)
def import_channels_config():
"""Import channel settings to the DB. Existing channels will be updated."""
@ -645,3 +647,41 @@ def find_orphaned_video_files(directory: pathlib.Path) -> Generator[pathlib.Path
''')
results = (pathlib.Path(j['path']) for i in curs.fetchall() for j in i[0])
yield from results
def format_videos_destination(channel_name: str = None, channel_tag: str = None, channel_url: str = None) \
-> pathlib.Path:
"""Return the directory where Videos should be downloaded according to the WROLPi Config.
@warning: Directory may or may not exist."""
videos_destination = get_wrolpi_config().videos_destination
channel_domain = ''
if channel_url:
try:
channel_domain = extract_domain(channel_url)
except Exception as e:
logger.error(f'Failed to extract domain from Channel URL: {channel_url}', exc_info=e)
if PYTEST:
raise
if channel_name and not isinstance(channel_name, str):
raise RuntimeError('channel name must be string')
if channel_tag and not isinstance(channel_tag, str):
raise RuntimeError('channel tag must be string')
name = escape_file_name(channel_name) if channel_name else ''
variables = dict(
channel_name=name,
channel_tag=channel_tag or '',
channel_domain=channel_domain,
)
try:
videos_destination = videos_destination % variables
except KeyError as e:
raise FileNotFoundError(f'Cannot download to the defined "videos_destination"') from e
videos_destination = get_media_directory() / videos_destination.lstrip('/')
return videos_destination

View file

@ -1,6 +1,5 @@
import json
import pathlib
from pathlib import Path
from typing import Optional, Dict, List
from sqlalchemy import Column, Integer, String, Boolean, JSON, Date, ForeignKey, BigInteger
@ -11,12 +10,12 @@ from modules.videos.errors import UnknownVideo, UnknownChannel
from wrolpi.captions import read_captions
from wrolpi.common import Base, ModelHelper, logger, get_media_directory, background_task, replace_file
from wrolpi.db import get_db_curs, get_db_session, optional_session
from wrolpi.downloader import Download, download_manager
from wrolpi.downloader import Download, download_manager, save_downloads_config
from wrolpi.errors import InvalidDownload
from wrolpi.files.lib import refresh_files, split_path_stem_and_suffix
from wrolpi.files.lib import refresh_files, split_path_stem_and_suffix, move
from wrolpi.files.models import FileGroup
from wrolpi.media_path import MediaPathType
from wrolpi.tags import Tag, TagFile
from wrolpi.tags import Tag, TagFile, save_tags_config
from wrolpi.vars import PYTEST, DELETED_VIDEO_KEYS
logger = logger.getChild(__name__)
@ -533,10 +532,16 @@ class Channel(ModelHelper, Base):
videos: InstrumentedList = relationship('Video', primaryjoin='Channel.id==Video.channel_id')
downloads: InstrumentedList = relationship('Download', primaryjoin='Download.channel_id==Channel.id')
tag_id = Column(Integer, ForeignKey('tag.id', ondelete='CASCADE'))
tag = relationship('Tag')
def __repr__(self):
return f'<Channel id={self.id} name={repr(self.name)} url={self.url} directory={self.directory}>'
@property
def tag_name(self) -> str | None:
return self.tag.name if self.tag else None
def delete_with_videos(self):
"""Delete all Video records (but not video files) related to this Channel. Then delete the Channel."""
session = Session.object_session(self)
@ -560,12 +565,20 @@ class Channel(ModelHelper, Base):
channel_id = self.id
data = data.copy()
# Remove unused values.
data.pop('move_to_tag_directory', None)
# URL should not be empty string.
if 'url' in data:
data['url'] = data['url'] or None
session: Session = Session.object_session(self)
downloads = data.pop('downloads', [])
if tag_name := data.pop('tag_name', None):
self.tag = Tag.find_by_name(tag_name, session)
for key, value in data.items():
setattr(self, key, value)
@ -575,8 +588,6 @@ class Channel(ModelHelper, Base):
elif isinstance(self.directory, str) and not pathlib.Path(self.directory).is_absolute():
self.directory = get_media_directory() / self.directory
session: Session = Session.object_session(self)
for download in downloads:
url = None
frequency = None
@ -608,11 +619,12 @@ class Channel(ModelHelper, Base):
config = dict(
calculate_duration=self.calculate_duration,
directory=str(self.directory),
downloads=[{'url': i.url, 'frequency': i.frequency} for i in self.downloads],
generate_posters=self.generate_posters,
name=self.name,
source_id=self.source_id,
tag_name=self.tag_name,
url=self.url or None,
downloads=[{'url': i.url, 'frequency': i.frequency} for i in self.downloads]
)
return config
@ -627,16 +639,18 @@ class Channel(ModelHelper, Base):
def __json__(self) -> dict:
d = dict(
downloads=self.downloads,
directory=self.directory,
downloads=self.downloads,
id=self.id,
name=self.name,
tag_name=self.tag_name,
url=self.url,
)
return d
def dict(self, with_statistics: bool = False, with_downloads: bool = True) -> dict:
d = super(Channel, self).dict()
d['tag_name'] = self.tag_name
d['directory'] = \
self.directory.relative_to(get_media_directory()) if self.directory else None
if with_statistics:
@ -706,10 +720,9 @@ class Channel(ModelHelper, Base):
"""Find a Channel with the provided id, raises an exception when no Channel is found.
@raise UnknownChannel: if the channel can not be found"""
channel = Channel.get_by_id(id_, session=session)
if not channel:
raise UnknownChannel(f'Cannot find channel with id {id_}')
return channel
if channel := Channel.get_by_id(id_, session=session):
return channel
raise UnknownChannel(f'Cannot find channel with id {id_}')
def get_video_entry_by_id(self, video_source_id: str) -> Optional[Dict]:
"""Search my info_json for the entry with the provided id."""
@ -758,3 +771,40 @@ class Channel(ModelHelper, Base):
raise RuntimeError('Must provide URL to get Channel')
channel = session.query(Channel).filter_by(url=url).one_or_none()
return channel
async def move(self, directory: pathlib.Path, session: Session):
"""Move the files of this Channel into a new directory."""
if not directory.is_dir():
raise FileNotFoundError(f'Destination directory does not exist: {directory}')
if self.directory and not next(self.directory.iterdir(), None):
raise FileExistsError(f'Refusing to move Channel when it has no files.')
old_directory = self.directory
self.directory = directory
session.flush([self, ])
# Change destination of all Downloads of this Channel, or any Downloads which download into this Channel's
# directory.
downloads = list(self.downloads)
downloads.extend(list(
session.query(Download).filter(Download.settings.contains({'destination': str(old_directory)}))
))
for download in downloads:
# Get new copy of `settings` to avoid bug where it wouldn't update.
settings = download.settings.copy() if download.settings else dict()
if settings.get('destination'):
download.settings = {**settings, 'destination': str(directory)}
session.commit()
# Save configs before move, this is because move imports configs.
from modules.videos.lib import save_channels_config
await save_downloads_config(session)
save_channels_config(session)
save_tags_config(session)
# Move the contents of the Channel directory into the destination directory.
await move(directory, *list(old_directory.iterdir()))
if old_directory.exists() and not next(old_directory.iterdir(), None):
old_directory.rmdir()

View file

@ -37,6 +37,21 @@ class ChannelPutRequest:
self.url = self.url.strip() if self.url else None
@dataclass
class ChannelTagRequest:
tag_name: Optional[str] = None
directory: Optional[str] = None
def __post_init__(self):
if self.directory and self.directory.startswith('/'):
raise ValidationError('Directory must be relative to media directory')
@dataclass
class ChannelTagInfoRequest:
tag_name: Optional[str] = None
@dataclass
class ChannelDownloadRequest:
url: str

View file

@ -332,7 +332,7 @@ def test_get_videos_directory(test_directory):
def test_get_custom_videos_directory(test_directory, test_config):
"""Custom directory can be used for videos directory."""
config = get_wrolpi_config()
config.videos_directory = 'custom/directory/videos'
config.videos_destination = 'custom/directory/videos'
assert common.get_videos_directory() == (test_directory / 'custom/directory/videos')
assert (test_directory / 'custom/directory/videos').is_dir()

View file

@ -19,7 +19,7 @@ example_video_json = {
'categories': ['Howto & Style'],
'channel': 'channel name',
'channel_id': 'channel id',
'channel_url': 'channel url',
'channel_url': 'https://example.com/example_video_json_channel',
'duration': 635,
'extractor': 'youtube',
'extractor_key': 'Youtube',
@ -31,7 +31,7 @@ example_video_json = {
'upload_date': '20190707',
'uploader': 'uploader name',
'uploader_id': 'uploader id',
'uploader_url': 'uploader url',
'uploader_url': 'https://example.com/example_video_json_uploader',
'view_count': 406,
'webpage_url': 'webpage url',
'webpage_url_basename': 'watch',

View file

@ -5,8 +5,9 @@ import pytest
from modules.videos import lib
from modules.videos.downloader import ChannelDownloader, VideoDownloader
from modules.videos.lib import parse_video_file_name, validate_video, get_statistics
from modules.videos.lib import parse_video_file_name, validate_video, get_statistics, format_videos_destination
from modules.videos.models import Video, Channel
from wrolpi.common import get_wrolpi_config
from wrolpi.downloader import Download, RSSDownloader
from wrolpi.files import lib as files_lib
from wrolpi.files.models import FileGroup
@ -20,7 +21,7 @@ from wrolpi.vars import PROJECT_DIR
('20000101foo.mp4', (None, None, None, '20000101foo')),
('something 20000101 foo.mp4', (None, None, None, 'something 20000101 foo')),
('something_20000101_foo.mp4', ('something', '20000101', None, 'foo')),
('foo .mp4', (None, None, None, 'foo')), # Has trailing whitespace.
('foo .mp4', (None, None, None, 'foo')), # Has trailing whitespace.
('NA_20000303_vp91w5_Bob&apos;s Pancakes.mp4', (None, '20000303', 'vp91w5', 'Bob&apos;s Pancakes')),
('NA_NA_vp91w5_Bob&apos;s Pancakes.mp4', (None, None, 'vp91w5', 'Bob&apos;s Pancakes')),
('Learning Self-Reliance_20170529_p_MzsCFkUPU_Beekeeping 2017 Part 6 - Merging Hives.mp4',
@ -216,3 +217,33 @@ def test_link_channel_and_downloads_migration(test_async_client, test_session, c
assert d2b.channel_id
assert d2c.url == 'https://example.com/channel2/video/1' and d2c.frequency is None
assert not d2c.channel_id
@pytest.mark.asyncio
async def test_format_videos_destination(test_async_client, test_directory):
"""Videos destination is formatted according to the WROLPiConfig."""
wrolpi_config = get_wrolpi_config()
# Channel directory without a tag.
wrolpi_config.videos_destination = 'videos/%(channel_tag)s/%(channel_name)s'
assert format_videos_destination('Simple Channel', None, None) \
== test_directory / 'videos/Simple Channel'
# One tag can be applied to a Channel.
assert format_videos_destination('Simple Channel', 'one', None) \
== test_directory / 'videos/one/Simple Channel'
# Channel Domain is also supported.
wrolpi_config.videos_destination = 'videos/%(channel_tag)s/%(channel_domain)s/%(channel_name)s'
assert format_videos_destination('Simple Channel', 'one', 'https://example.com') \
== test_directory / 'videos/one/example.com/Simple Channel'
# Channel name is not required.
wrolpi_config.videos_destination = '%(channel_tag)s/%(channel_domain)s'
assert format_videos_destination('Simple Channel', 'one', 'https://example.com') \
== test_directory / 'one/example.com'
# Invalid `videos_destination` raises an error.
wrolpi_config.videos_destination = '%(channel_tag)s/%(channel)s'
with pytest.raises(FileNotFoundError):
assert format_videos_destination()

View file

@ -1,5 +1,10 @@
import pathlib
import pytest
from modules.videos.models import Video, Channel
from wrolpi.common import get_relative_to_media_directory, get_wrolpi_config
@pytest.mark.asyncio()
async def test_ffprobe_stream_methods(simple_video):
@ -13,3 +18,24 @@ async def test_ffprobe_stream_methods(simple_video):
assert simple_video.get_streams_by_codec_name('h264')
assert simple_video.get_streams_by_codec_name('aac')
assert simple_video.get_streams_by_codec_name('mov_text')
@pytest.mark.asyncio
async def test_channel_move(test_async_client, test_session, test_directory, channel_factory, video_factory):
"""A Channel can be moved to another directory, any files in the Channel's directory are moved."""
channel = channel_factory(name='Channel Name')
video = video_factory(title='Vid', channel_id=channel.id)
test_session.commit()
assert video.channel_id == channel.id
assert str(get_relative_to_media_directory(video.video_path)) == 'videos/Channel Name/Vid.mp4'
foo = test_directory / 'foo/New Channel Directory'
foo.mkdir(parents=True)
await channel.move(foo, test_session)
assert channel.directory == foo
assert channel.directory != (test_directory / 'New Channel Directory')
video = test_session.query(Video).one()
assert str(get_relative_to_media_directory(video.video_path)) == 'foo/New Channel Directory/Vid.mp4'
assert str(video.video_path).startswith(str(channel.directory))
# Old directory is deleted.
assert not (test_directory / 'Channel Name').exists()

View file

@ -85,7 +85,7 @@ def search_videos(
joins = list()
join_video = False
params = dict(search_str=search_str, offset=offset)
params = dict(search_str=search_str, offset=offset or 0)
if channel_id:
wheres.append('v.channel_id = %(channel_id)s')
joins.append('LEFT JOIN channel c ON c.id = v.channel_id')

View file

@ -464,7 +464,7 @@ def zim_download_url_to_name(url: str) -> Tuple[str, str]:
def get_zim_directory() -> pathlib.Path:
zim_directory = get_media_directory() / get_wrolpi_config().zims_directory
zim_directory = get_media_directory() / get_wrolpi_config().zims_destination
return zim_directory

View file

@ -385,6 +385,6 @@ def test_get_custom_zims_directory(test_directory, test_config):
"""Custom directory can be used for zims directory."""
assert lib.get_zim_directory() == (test_directory / 'zims')
get_wrolpi_config().zims_directory = 'custom/zims'
get_wrolpi_config().zims_destination = 'custom/zims'
assert lib.get_zim_directory() == (test_directory / 'custom/zims')

View file

@ -27,4 +27,4 @@ srt==3.5.3
vininfo[cli]==1.8.0
websockets==12.0
webvtt-py==0.4.6
yt-dlp==2024.8.1
yt-dlp==2024.8.6

View file

@ -10,7 +10,7 @@ import tempfile
from modules.archive import lib as archive_lib
from wrolpi.cmd import READABILITY_BIN
from wrolpi.common import html_screenshot, format_html_string, logger
from wrolpi.common import html_screenshot, format_html_string, logger, format_html_file
logger = logger.getChild('singlefile_to_archive')
@ -31,6 +31,8 @@ def singlefile_to_archive_files(singlefile: pathlib.Path):
"""Extract Readability files and Screenshot file next to the provided Singlefile."""
contents = singlefile.read_bytes()
format_html_file(singlefile)
readability_html_path = singlefile.with_suffix('.readability.html')
readability_json_path = singlefile.with_suffix('.readability.json')
readability_text_path = singlefile.with_suffix('.readability.txt')

View file

@ -276,7 +276,10 @@ class ModelHelper:
def flush(self):
"""A convenience function which flushes this record using its DB Session."""
Session.object_session(self).flush([self])
if session := Session.object_session(self):
session.flush([self])
else:
raise RuntimeError(f'{self} is not in a session!')
class tsvector(types.TypeDecorator):
@ -370,7 +373,8 @@ class ConfigFile:
if config_file.is_file():
# Use the config file to get the values the user set.
with config_file.open('rt') as fh:
self._config.update(yaml.load(fh, Loader=yaml.Loader))
config_data = yaml.load(fh, Loader=yaml.Loader)
self._config.update(config_data)
return self
def _get_backup_filename(self):
@ -467,7 +471,7 @@ class ConfigFile:
class WROLPiConfig(ConfigFile):
file_name = 'wrolpi.yaml'
default_config = dict(
archive_directory='archive',
archive_destination='archive/%(domain)s',
download_on_startup=True,
download_timeout=0,
hotspot_device='wlan0',
@ -476,12 +480,12 @@ class WROLPiConfig(ConfigFile):
hotspot_ssid='WROLPi',
ignore_outdated_zims=False,
ignored_directories=['config', 'tags'],
map_directory='map',
map_destination='map',
nav_color='violet',
throttle_on_startup=False,
videos_directory='videos',
videos_destination='videos/%(channel_tag)s/%(channel_name)s',
wrol_mode=False,
zims_directory='zims',
zims_destination='zims',
)
def get_file(self) -> Path:
@ -586,28 +590,36 @@ class WROLPiConfig(ConfigFile):
self.update({'ignored_directories': value})
@property
def videos_directory(self) -> str:
return self._config['videos_directory']
def videos_destination(self) -> str:
return self._config['videos_destination']
@videos_directory.setter
def videos_directory(self, value: str):
self.update({'videos_directory': value})
@videos_destination.setter
def videos_destination(self, value: str):
self.update({'videos_destination': value})
@property
def archive_directory(self) -> str:
return self._config['archive_directory']
def archive_destination(self) -> str:
return self._config['archive_destination']
@archive_directory.setter
def archive_directory(self, value: str):
self.update({'archive_directory': value})
@archive_destination.setter
def archive_destination(self, value: str):
self.update({'archive_destination': value})
@property
def map_directory(self) -> str:
return self._config['map_directory']
def map_destination(self) -> str:
return self._config['map_destination']
@map_directory.setter
def map_directory(self, value: str):
self.update({'map_directory': value})
@map_destination.setter
def map_destination(self, value: str):
self.update({'map_destination': value})
@property
def zims_destination(self) -> str:
return self._config['zims_destination']
@zims_destination.setter
def zims_destination(self, value: str):
self.update({'zims_destination': value})
@property
def nav_color(self) -> str:
@ -617,14 +629,6 @@ class WROLPiConfig(ConfigFile):
def nav_color(self, value: str):
self.update({'nav_color': value})
@property
def zims_directory(self) -> str:
return self._config['zims_directory']
@zims_directory.setter
def zims_directory(self, value: str):
self.update({'zims_directory': value})
WROLPI_CONFIG: WROLPiConfig = WROLPiConfig()
TEST_WROLPI_CONFIG: WROLPiConfig = None

View file

@ -36,6 +36,7 @@ import wrolpi.root_api # noqa
from wrolpi import flags
from wrolpi.api_utils import api_app
from wrolpi.common import iterify, log_level_context, enable_wrol_mode, disable_wrol_mode
from wrolpi.common import logger
from wrolpi.common import set_test_media_directory, Base, set_test_config
from wrolpi.contexts import attach_shared_contexts, initialize_configs_contexts
from wrolpi.dates import set_test_now
@ -47,6 +48,8 @@ from wrolpi.files.models import Directory, FileGroup
from wrolpi.tags import Tag, upsert_tag
from wrolpi.vars import PROJECT_DIR
logger = logger.getChild(__name__)
def get_test_db_engine():
suffix = str(uuid1()).replace('-', '')

View file

@ -27,7 +27,7 @@ from wrolpi import flags
from wrolpi.api_utils import api_app
from wrolpi.cmd import pid_is_running
from wrolpi.common import Base, ModelHelper, logger, wrol_mode_check, zig_zag, ConfigFile, WROLPI_CONFIG, \
limit_concurrent, wrol_mode_enabled
limit_concurrent, wrol_mode_enabled, background_task
from wrolpi.dates import TZDateTime, now, Seconds
from wrolpi.db import get_db_session, get_db_curs, optional_session
from wrolpi.errors import InvalidDownload, UnrecoverableDownloadError, UnknownDownload
@ -226,7 +226,7 @@ class Download(ModelHelper, Base): # noqa
from modules.videos.lib import save_channels_config
save_channels_config()
# Save download config again because this download is now removed from the download lists.
api_app.add_task(save_downloads_config())
background_task(save_downloads_config())
@staticmethod
def get_by_id(id_: int, session: Session = None) -> Optional[Base]:
@ -538,9 +538,9 @@ class DownloadManager:
session.flush(downloads)
try:
# Start downloading ASAP.
api_app.add_task(self.dispatch_downloads())
background_task(self.dispatch_downloads())
# Save the config now that new Downloads exist.
api_app.add_task(save_downloads_config())
background_task(save_downloads_config())
except RuntimeError:
# Event loop isn't running. Probably testing?
if not PYTEST:
@ -574,9 +574,6 @@ class DownloadManager:
from modules.videos.models import Channel
if channel := Channel.get_by_url(url=download.url, session=session):
download.channel_id = channel.id
if destination := (download.settings or dict()).get('destination'):
if channel := Channel.get_by_path(destination, session):
download.channel_id = channel.id
session.commit()
@ -668,7 +665,7 @@ class DownloadManager:
with get_db_curs(commit=True) as curs:
curs.execute("UPDATE download SET status='new' WHERE status='pending' OR status='deferred'")
api_app.add_task(save_downloads_config())
background_task(save_downloads_config())
DOWNLOAD_SORT = (
DownloadStatus.pending,
@ -742,7 +739,7 @@ class DownloadManager:
session.commit()
# Save the config now that some Downloads renewed.
api_app.add_task(save_downloads_config())
background_task(save_downloads_config())
@staticmethod
def get_downloads(session: Session) -> List[Download]:
@ -1025,7 +1022,7 @@ class DownloadManager:
stmt = self._delete_downloads_q(once=True)
deleted_ids = [i for i, in session.execute(stmt).fetchall()]
session.commit()
api_app.add_task(save_downloads_config())
background_task(save_downloads_config())
return deleted_ids
@staticmethod
@ -1130,7 +1127,7 @@ async def signal_download_download(download_id: int, download_url: str):
# Allow the download to resume.
download_manager.unkill_download(download_id)
# Save the config now that the Download has finished.
api_app.add_task(save_downloads_config())
background_task(save_downloads_config())
except asyncio.CancelledError as e:
worker_logger.warning('Canceled!', exc_info=e)
return

View file

@ -137,7 +137,7 @@ def get_settings(_: Request):
ignored_directories = [get_relative_to_media_directory(i) for i in config.ignored_directories]
settings = {
'archive_directory': config.archive_directory,
'archive_directory': config.archive_destination,
'download_manager_disabled': download_manager.is_disabled,
'download_manager_stopped': download_manager.is_stopped,
'download_on_startup': config.download_on_startup,
@ -150,15 +150,15 @@ def get_settings(_: Request):
'ignore_outdated_zims': config.ignore_outdated_zims,
'ignored_directories': ignored_directories,
'log_level': api_app.shared_ctx.log_level.value,
'map_directory': config.map_directory,
'map_directory': config.map_destination,
'nav_color': config.nav_color,
'media_directory': str(get_media_directory()), # Convert to string to avoid conversion to relative.
'throttle_on_startup': config.throttle_on_startup,
'throttle_status': admin.throttle_status().name,
'version': __version__,
'videos_directory': config.videos_directory,
'videos_directory': config.videos_destination,
'wrol_mode': config.wrol_mode,
'zims_directory': config.zims_directory,
'zims_directory': config.zims_destination,
}
return json_response(settings)
@ -190,25 +190,25 @@ async def update_settings(_: Request, body: schema.SettingsRequest):
if not config:
raise InvalidConfig()
if body.archive_directory and pathlib.Path(body.archive_directory).is_absolute():
if body.archive_destination and pathlib.Path(body.archive_destination).is_absolute():
raise InvalidConfig('Archive directory must be relative to media directory')
elif not body.archive_directory:
config['archive_directory'] = wrolpi_config.default_config['archive_directory']
elif not body.archive_destination:
config['archive_destination'] = wrolpi_config.default_config['archive_destination']
if body.videos_directory and pathlib.Path(body.videos_directory).is_absolute():
if body.videos_destination and pathlib.Path(body.videos_destination).is_absolute():
raise InvalidConfig('Videos directory must be relative to media directory')
elif not body.videos_directory:
config['videos_directory'] = wrolpi_config.default_config['videos_directory']
elif not body.videos_destination:
config['videos_destination'] = wrolpi_config.default_config['videos_destination']
if body.map_directory and pathlib.Path(body.map_directory).is_absolute():
if body.map_destination and pathlib.Path(body.map_destination).is_absolute():
raise InvalidConfig('Map directory must be relative to media directory')
elif not body.map_directory:
config['map_directory'] = wrolpi_config.default_config['map_directory']
elif not body.map_destination:
config['map_destination'] = wrolpi_config.default_config['map_destination']
if body.zims_directory and pathlib.Path(body.zims_directory).is_absolute():
if body.zims_destination and pathlib.Path(body.zims_destination).is_absolute():
raise InvalidConfig('Zims directory must be relative to media directory')
elif not body.zims_directory:
config['zims_directory'] = wrolpi_config.default_config['zims_directory']
elif not body.zims_destination:
config['zims_destination'] = wrolpi_config.default_config['zims_destination']
log_level = config.pop('log_level', None)
if isinstance(log_level, int):

View file

@ -32,7 +32,7 @@ class SemanticUIColors(enum.StrEnum):
@dataclass
class SettingsRequest:
archive_directory: Optional[str] = None
archive_destination: Optional[str] = None
download_on_startup: Optional[bool] = None
download_timeout: Optional[int] = None
hotspot_device: Optional[str] = None
@ -42,14 +42,14 @@ class SettingsRequest:
hotspot_status: Optional[bool] = None
ignore_outdated_zims: Optional[bool] = None
log_level: Optional[int] = None
map_directory: Optional[str] = None
map_destination: Optional[str] = None
nav_color: Optional[str] = None
media_directory: Optional[str] = None
throttle_on: Optional[bool] = None
throttle_on_startup: Optional[bool] = None
videos_directory: Optional[str] = None
videos_destination: Optional[str] = None
wrol_mode: Optional[bool] = None
zims_directory: Optional[str] = None
zims_destination: Optional[str] = None
def __post_init__(self):
self.nav_color = self.nav_color.lower() if self.nav_color else None

View file

@ -426,7 +426,6 @@ def delete_tag(tag_id: int, session: Session = None):
sync_tags_directory()
@register_refresh_cleanup
@optional_session
def import_tags_config(session: Session = None):
"""Reads the Tags and TagFiles from the config file, upserts them in the DB."""

View file

@ -530,14 +530,15 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto
request, response = await test_async_client.post('/api/search_suggestions', json=body)
assert response.status_code == HTTPStatus.OK
if expected_channels:
assert response.json['channels'] == expected_channels
for channel, expected_channel in zip_longest(response.json['channels'], expected_channels):
assert_dict_contains(channel, expected_channel)
assert response.json['domains'] == expected_domains
await assert_results(
dict(search_str='foo'),
[
{'directory': 'Fool', 'id': 2, 'name': 'Fool', 'url': 'https://example.com/Fool', 'downloads': []},
{'directory': 'Foo', 'id': 1, 'name': 'Foo', 'url': 'https://example.com/Foo', 'downloads': []},
{'directory': 'videos/Fool', 'id': 2, 'name': 'Fool', 'url': 'https://example.com/Fool', 'downloads': []},
{'directory': 'videos/Foo', 'id': 1, 'name': 'Foo', 'url': 'https://example.com/Foo', 'downloads': []},
],
[{'directory': 'archive/foo.com', 'domain': 'foo.com', 'id': 1}],
)
@ -546,14 +547,14 @@ async def test_search_suggestions(test_session, test_async_client, channel_facto
await assert_results(
dict(search_str='foo l'),
[
{'directory': 'Fool', 'id': 2, 'name': 'Fool', 'url': 'https://example.com/Fool', 'downloads': []}],
{'directory': 'videos/Fool', 'id': 2, 'name': 'Fool', 'url': 'https://example.com/Fool', 'downloads': []}],
[],
)
await assert_results(
dict(search_str='bar'),
[
{'directory': 'Bar', 'id': 3, 'name': 'Bar', 'url': 'https://example.com/Bar', 'downloads': []}
{'directory': 'videos/Bar', 'id': 3, 'name': 'Bar', 'url': 'https://example.com/Bar', 'downloads': []}
],
[{'directory': 'archive/bar.com', 'domain': 'bar.com', 'id': 2}],
)
@ -705,29 +706,28 @@ def test_recursive_errors():
@pytest.mark.asyncio
async def test_settings_special_directories(test_async_client, test_config):
"""Maintainer can change special directories."""
assert get_wrolpi_config().archive_directory == 'archive'
data = {'archive_directory': 'archives'}
data = {'archive_destination': 'archives'}
request, response = await test_async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.NO_CONTENT
assert get_wrolpi_config().archive_directory == 'archives'
assert get_wrolpi_config().archive_destination == 'archives'
data = {'archive_directory': '/absolute/not/allowed'}
data = {'archive_destination': '/absolute/not/allowed'}
request, response = await test_async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.BAD_REQUEST
data = {'videos_directory': '/absolute/not/allowed'}
data = {'videos_destination': '/absolute/not/allowed'}
request, response = await test_async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.BAD_REQUEST
data = {'map_directory': '/absolute/not/allowed'}
data = {'map_destination': '/absolute/not/allowed'}
request, response = await test_async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.BAD_REQUEST
data = {'zims_directory': '/absolute/not/allowed'}
data = {'zims_destination': '/absolute/not/allowed'}
request, response = await test_async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.BAD_REQUEST
# Empty directory restores default.
data = {'archive_directory': ''}
data = {'archive_destination': ''}
request, response = await test_async_client.patch('/api/settings', content=json.dumps(data))
assert response.status_code == HTTPStatus.NO_CONTENT
assert get_wrolpi_config().archive_directory == 'archive'
assert get_wrolpi_config().archive_destination == 'archive/%(domain)s'