mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Allowing user to configure collection download destination directories, as well as directories in the file structure. This will allow sorting archives by destination year, for example.
This commit is contained in:
parent
3f4d5798e2
commit
1ebcae0965
21 changed files with 780 additions and 160 deletions
|
|
@ -429,6 +429,19 @@ export async function updateVideoDownloaderConfig(config) {
|
|||
return await updateConfig('videos_downloader.yaml', config);
|
||||
}
|
||||
|
||||
export async function fetchArchiveDownloaderConfig() {
|
||||
return await getConfig('archives_downloader.yaml');
|
||||
}
|
||||
|
||||
export async function updateArchiveDownloaderConfig(config) {
|
||||
return await updateConfig('archives_downloader.yaml', config);
|
||||
}
|
||||
|
||||
export async function postArchiveFileFormat(archive_file_format) {
|
||||
const body = {archive_file_format};
|
||||
return await apiPost(`${ARCHIVES_API}/file_format`, body);
|
||||
}
|
||||
|
||||
export async function fetchBrowserProfiles() {
|
||||
const response = await apiGet(`${API_URI}/videos/browser-profiles`);
|
||||
if (response.status === 200) {
|
||||
|
|
|
|||
|
|
@ -43,14 +43,18 @@ import {
|
|||
import {
|
||||
deleteArchives,
|
||||
deleteDomain,
|
||||
fetchArchiveDownloaderConfig,
|
||||
generateArchiveScreenshot,
|
||||
getCollectionTagInfo,
|
||||
postArchiveFileFormat,
|
||||
postDownload,
|
||||
refreshDomain,
|
||||
tagDomain,
|
||||
tagFileGroup,
|
||||
untagFileGroup
|
||||
untagFileGroup,
|
||||
updateArchiveDownloaderConfig,
|
||||
} from "../api";
|
||||
import {InputForm, useForm} from "../hooks/useForm";
|
||||
import {CollectionTagModal} from "./collections/CollectionTagModal";
|
||||
import {Link, Route, Routes, useLocation, useNavigate, useParams} from "react-router";
|
||||
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
|
||||
|
|
@ -658,9 +662,67 @@ export function DomainEditPage() {
|
|||
</>;
|
||||
}
|
||||
|
||||
function ArchiveFileNameForm({form}) {
|
||||
const [message, setMessage] = React.useState(null);
|
||||
|
||||
const onChange = async (value) => {
|
||||
const response = await postArchiveFileFormat(value);
|
||||
const {error, preview} = await response.json();
|
||||
if (error) {
|
||||
setMessage({content: error, header: 'Invalid File Name', negative: true});
|
||||
} else {
|
||||
setMessage({content: preview, header: 'File Name Preview', positive: true});
|
||||
}
|
||||
}
|
||||
|
||||
const label = <InfoHeader
|
||||
headerSize='h5'
|
||||
headerContent='Archive File Format'
|
||||
popupProps={{wide: 'very', position: 'top left'}}
|
||||
popupContent={<>
|
||||
<p>Variables:</p>
|
||||
<ul>
|
||||
<li><code>%(title)s</code> - Page title</li>
|
||||
<li><code>%(download_datetime)s</code> - Full datetime (YYYY-MM-DD-HH-MM-SS)</li>
|
||||
<li><code>%(download_date)s</code> - Date only (YYYY-MM-DD)</li>
|
||||
<li><code>%(download_year)s</code> - Year</li>
|
||||
<li><code>%(download_month)s</code> - Month (zero-padded)</li>
|
||||
<li><code>%(download_day)s</code> - Day (zero-padded)</li>
|
||||
<li><code>%(domain)s</code> - Domain name</li>
|
||||
</ul>
|
||||
<p>Subdirectories supported: <code>%(download_year)s/%(title)s</code></p>
|
||||
</>}
|
||||
/>;
|
||||
|
||||
return <InputForm
|
||||
form={form}
|
||||
name='file_name_format'
|
||||
path='file_name_format'
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
message={message}
|
||||
/>
|
||||
}
|
||||
|
||||
function ArchiveSettingsPage() {
|
||||
useTitle('Archive Settings');
|
||||
|
||||
const {t} = React.useContext(ThemeContext);
|
||||
|
||||
const emptyFormData = {
|
||||
file_name_format: '%(download_datetime)s_%(title)s',
|
||||
};
|
||||
|
||||
const configSubmitter = async () => {
|
||||
return await updateArchiveDownloaderConfig(configForm.formData);
|
||||
};
|
||||
|
||||
const configForm = useForm({
|
||||
fetcher: fetchArchiveDownloaderConfig,
|
||||
submitter: configSubmitter,
|
||||
emptyFormData,
|
||||
});
|
||||
|
||||
const urlClipboardButton = <APIButton
|
||||
icon='copy'
|
||||
onClick={() => navigator.clipboard.writeText(API_ARCHIVE_UPLOAD_URI)}
|
||||
|
|
@ -675,29 +737,56 @@ function ArchiveSettingsPage() {
|
|||
/>;
|
||||
|
||||
return <PageContainer>
|
||||
<Header as='h1'>SingleFile Browser Extension</Header>
|
||||
<Segment>
|
||||
<Header as='h3'>Archive Downloader Config</Header>
|
||||
|
||||
<p {...t}>
|
||||
These are the settings necessary to configure the <a
|
||||
href="https://github.com/gildas-lormeau/SingleFile?tab=readme-ov-file#install">SingleFile Browser
|
||||
Extension</a> to automatically upload to your WROLPi.
|
||||
</p>
|
||||
<Form>
|
||||
<Grid>
|
||||
<GridRow columns={1}>
|
||||
<GridColumn mobile={16} computer={8}>
|
||||
<ArchiveFileNameForm form={configForm}/>
|
||||
</GridColumn>
|
||||
</GridRow>
|
||||
<GridRow columns={1}>
|
||||
<GridColumn textAlign='right'>
|
||||
<APIButton
|
||||
disabled={configForm.disabled || !configForm.ready}
|
||||
type='submit'
|
||||
style={{marginTop: '0.5em'}}
|
||||
onClick={configForm.onSubmit}
|
||||
id='archive_settings_save_button'
|
||||
>Save</APIButton>
|
||||
</GridColumn>
|
||||
</GridRow>
|
||||
</Grid>
|
||||
</Form>
|
||||
</Segment>
|
||||
|
||||
<label {...t}>Upload URL</label>
|
||||
<Input fluid
|
||||
value={API_ARCHIVE_UPLOAD_URI}
|
||||
label={urlClipboardButton}
|
||||
/>
|
||||
<label {...t}>Data Field Name</label>
|
||||
<Input fluid
|
||||
value='singlefile_contents'
|
||||
label={dataFieldNameClipboardButton}
|
||||
/>
|
||||
<label {...t}>URL Field Name</label>
|
||||
<Input fluid
|
||||
value='url'
|
||||
label={urlFieldNameClipboardButton}
|
||||
/>
|
||||
<Segment>
|
||||
<Header as='h3'>SingleFile Browser Extension</Header>
|
||||
|
||||
<p {...t}>
|
||||
These are the settings necessary to configure the <a
|
||||
href="https://github.com/gildas-lormeau/SingleFile?tab=readme-ov-file#install">SingleFile Browser
|
||||
Extension</a> to automatically upload to your WROLPi.
|
||||
</p>
|
||||
|
||||
<label {...t}>Upload URL</label>
|
||||
<Input fluid
|
||||
value={API_ARCHIVE_UPLOAD_URI}
|
||||
label={urlClipboardButton}
|
||||
/>
|
||||
<label {...t}>Data Field Name</label>
|
||||
<Input fluid
|
||||
value='singlefile_contents'
|
||||
label={dataFieldNameClipboardButton}
|
||||
/>
|
||||
<label {...t}>URL Field Name</label>
|
||||
<Input fluid
|
||||
value='url'
|
||||
label={urlFieldNameClipboardButton}
|
||||
/>
|
||||
</Segment>
|
||||
</PageContainer>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -759,6 +759,7 @@ export function InfoPopup({
|
|||
iconSize = null,
|
||||
header = '',
|
||||
iconStyle = {marginLeft: '0.25em', marginRight: '0.25em', marginTop: '-0.5em'},
|
||||
...props
|
||||
}) {
|
||||
const trigger = <Icon link name={icon} size={iconSize} style={iconStyle}/>;
|
||||
return <Popup
|
||||
|
|
@ -768,6 +769,7 @@ export function InfoPopup({
|
|||
header={header || null}
|
||||
trigger={trigger}
|
||||
hoverable={true}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
|
||||
|
|
@ -778,6 +780,7 @@ export function InfoHeader({
|
|||
headerContent,
|
||||
popupContent,
|
||||
popupPosition = null,
|
||||
popupProps = {},
|
||||
for_ = null,
|
||||
required = false,
|
||||
...props
|
||||
|
|
@ -787,7 +790,7 @@ export function InfoHeader({
|
|||
<Header as={headerSize}>{headerContent} {required && <RequiredAsterisk/>}</Header>
|
||||
</label>
|
||||
<span>
|
||||
<InfoPopup content={popupContent} iconSize={iconSize} icon={icon} position={popupPosition}/>
|
||||
<InfoPopup content={popupContent} iconSize={iconSize} icon={icon} position={popupPosition} {...popupProps}/>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -244,17 +244,37 @@ function VideoFileNameForm({form}) {
|
|||
}
|
||||
}
|
||||
|
||||
const label = <InfoHeader
|
||||
headerSize='h5'
|
||||
headerContent='Video File Format'
|
||||
popupProps={{wide: 'very', position: 'top left'}}
|
||||
popupContent={<>
|
||||
<p>Common yt-dlp variables:</p>
|
||||
<ul>
|
||||
<li><code>%(title)s</code> - Video title</li>
|
||||
<li><code>%(uploader)s</code> - Channel/uploader name</li>
|
||||
<li><code>%(upload_date)s</code> - Upload date (YYYYMMDD)</li>
|
||||
<li><code>%(id)s</code> - Video ID</li>
|
||||
<li><code>%(ext)s</code> - File extension</li>
|
||||
<li><code>%(channel)s</code> - Channel name</li>
|
||||
<li><code>%(duration)s</code> - Duration in seconds</li>
|
||||
<li><code>%(playlist_index)s</code> - Playlist index</li>
|
||||
</ul>
|
||||
<p>See yt-dlp docs for all variables. Subdirectories supported.</p>
|
||||
</>}
|
||||
/>;
|
||||
|
||||
return <InputForm
|
||||
form={form}
|
||||
name='file_name_format'
|
||||
path='yt_dlp_options.file_name_format'
|
||||
label='Video File Format'
|
||||
label={label}
|
||||
onChange={onChange}
|
||||
message={message}
|
||||
/>
|
||||
}
|
||||
|
||||
function VideosSettings() {
|
||||
function VideosSettingsPage() {
|
||||
useTitle('Videos Settings');
|
||||
|
||||
const emptyFormData = {
|
||||
|
|
@ -301,7 +321,7 @@ function VideosSettings() {
|
|||
if (_.isEmpty(tempProfiles)) {
|
||||
tempProfiles = emptyProfilesOptions;
|
||||
}
|
||||
console.debug('VideosSettings: Got browser profiles:', tempProfiles);
|
||||
console.debug('VideosSettingsPage: Got browser profiles:', tempProfiles);
|
||||
setBrowserProfilesOptions(tempProfiles);
|
||||
};
|
||||
|
||||
|
|
@ -523,7 +543,7 @@ export function VideosRoute(props) {
|
|||
<Routes>
|
||||
<Route path='/' exact element={<VideosPage/>}/>
|
||||
<Route path='channel' exact element={<ChannelsPage/>}/>
|
||||
<Route path='settings' exact element={<VideosSettings/>}/>
|
||||
<Route path='settings' exact element={<VideosSettingsPage/>}/>
|
||||
<Route path='statistics' exact element={<VideosStatistics/>}/>
|
||||
<Route path='channel/new' exact element={<ChannelNewPage/>}/>
|
||||
<Route path='channel/:channelId/edit' exact element={<ChannelEditPage/>}/>
|
||||
|
|
|
|||
|
|
@ -353,7 +353,7 @@ export const useSearchArchives = (defaultLimit) => {
|
|||
|
||||
useEffect(() => {
|
||||
localSearchArchives();
|
||||
}, [searchStr, limit, domain, order, activePage, JSON.stringify(activeTags), headline]);
|
||||
}, [searchStr, limit, domain, order, offset, JSON.stringify(activeTags), headline]);
|
||||
|
||||
const setSearchStr = (value) => {
|
||||
updateQuery({q: value, o: 0, order: undefined});
|
||||
|
|
|
|||
20
main.py
20
main.py
|
|
@ -215,9 +215,19 @@ async def initialize_configs(app: Sanic):
|
|||
async def handle_server_shutdown(*args, **kwargs):
|
||||
"""Stop downloads when server is shutting down."""
|
||||
logger.warning('Shutting down')
|
||||
download_manager.stop()
|
||||
await cancel_refresh_tasks()
|
||||
await cancel_background_tasks()
|
||||
try:
|
||||
download_manager.stop()
|
||||
except AttributeError:
|
||||
# Ignore AttributeError when context is empty, which can happen during server shutdown
|
||||
pass
|
||||
try:
|
||||
await cancel_refresh_tasks()
|
||||
except Exception as e:
|
||||
logger.error('cancel_refresh_tasks failed. This is probably fine.', exc_info=e)
|
||||
try:
|
||||
await cancel_background_tasks()
|
||||
except Exception as e:
|
||||
logger.error('cancel_background_tasks failed. This is probably fine', exc_info=e)
|
||||
|
||||
|
||||
@api_app.signal(Event.SERVER_SHUTDOWN_AFTER)
|
||||
|
|
@ -264,6 +274,10 @@ async def start_single_tasks(app: Sanic):
|
|||
with log_and_suppress(Exception, message='Failed to import domains config'):
|
||||
import_domains_config()
|
||||
logger.debug('domains config imported')
|
||||
with log_and_suppress(Exception, message='Failed to import archive downloader config'):
|
||||
from modules.archive.lib import get_archive_downloader_config
|
||||
get_archive_downloader_config().import_config()
|
||||
logger.debug('archive downloader config imported')
|
||||
with log_and_suppress(Exception, message='Failed to import inventories config'):
|
||||
import_inventories_config()
|
||||
logger.debug('inventories config imported')
|
||||
|
|
|
|||
|
|
@ -49,12 +49,13 @@ class ArchiveDownloader(Downloader, ABC):
|
|||
if download.settings and download.settings.get('destination'):
|
||||
destination = pathlib.Path(download.settings['destination'])
|
||||
|
||||
# If no destination provided, look up the domain collection's directory
|
||||
# If no destination provided, use the domain collection's directory
|
||||
# get_or_set_directory will format from template and save on first use
|
||||
if not destination:
|
||||
with get_db_session() as session:
|
||||
with get_db_session(commit=True) as session:
|
||||
collection = lib.get_or_create_domain_collection(session, download.url)
|
||||
if collection and collection.directory:
|
||||
destination = collection.directory
|
||||
if collection:
|
||||
destination = collection.get_or_set_directory(session)
|
||||
|
||||
if DOCKERIZED or PYTEST:
|
||||
# Perform the archive in the Archive docker container. (Typically in the development environment).
|
||||
|
|
|
|||
|
|
@ -70,6 +70,26 @@ async def search_archives(_: Request, body: schema.ArchiveSearchRequest):
|
|||
return json_response(ret)
|
||||
|
||||
|
||||
@archive_bp.post('/file_format')
|
||||
@openapi.definition(
|
||||
description='Preview the archive file format',
|
||||
body=schema.ArchiveFileFormatRequest,
|
||||
)
|
||||
@validate(schema.ArchiveFileFormatRequest)
|
||||
async def post_file_format(_: Request, body: schema.ArchiveFileFormatRequest):
|
||||
try:
|
||||
preview = lib.preview_archive_filename(body.archive_file_format)
|
||||
return json_response(dict(
|
||||
archive_file_format=body.archive_file_format,
|
||||
preview=preview,
|
||||
))
|
||||
except RuntimeError as e:
|
||||
return json_response(dict(
|
||||
error=str(e),
|
||||
archive_file_format=body.archive_file_format,
|
||||
), status=HTTPStatus.BAD_REQUEST)
|
||||
|
||||
|
||||
@archive_bp.get('/upload')
|
||||
@openapi.definition(
|
||||
summary='A message to confirm to the user that they have the correct upload URL.'
|
||||
|
|
@ -106,7 +126,17 @@ async def singlefile_upload_switch_handler(url=None):
|
|||
logger.info(f'singlefile_upload_switch_handler queue size: {q_size}')
|
||||
|
||||
try:
|
||||
archive = await lib.singlefile_to_archive(singlefile)
|
||||
# Get URL from singlefile to determine destination
|
||||
url = lib.get_url_from_singlefile(singlefile)
|
||||
|
||||
# Get/create domain collection and get/set its directory
|
||||
destination = None
|
||||
with get_db_session(commit=True) as session:
|
||||
collection = lib.get_or_create_domain_collection(session, url)
|
||||
if collection:
|
||||
destination = collection.get_or_set_directory(session)
|
||||
|
||||
archive = await lib.singlefile_to_archive(singlefile, destination=destination)
|
||||
except Exception as e:
|
||||
logger.error(f'singlefile_upload_switch_handler failed', exc_info=e)
|
||||
Events.send_upload_archive_failed(f'Failed to convert singlefile to archive: {e}')
|
||||
|
|
|
|||
|
|
@ -213,6 +213,143 @@ def import_domains_config():
|
|||
logger.info('Importing domains config completed')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveDownloaderConfigValidator:
|
||||
"""Validator for archives_downloader.yaml config."""
|
||||
file_name_format: str
|
||||
version: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
# Validate file_name_format contains required %(title)s variable
|
||||
if '%(title)s' not in self.file_name_format:
|
||||
raise ValueError('file_name_format must contain %(title)s')
|
||||
|
||||
|
||||
class ArchiveDownloaderConfig(ConfigFile):
|
||||
"""
|
||||
Config file for Archive Downloader settings.
|
||||
|
||||
This config mirrors videos_downloader.yaml and holds settings for how
|
||||
archives are downloaded and named.
|
||||
|
||||
Format:
|
||||
version: 0
|
||||
file_name_format: '%(download_date)s_%(title)s'
|
||||
|
||||
Variables available in file_name_format:
|
||||
- %(title)s - Page title (extracted from HTML)
|
||||
- %(download_datetime)s - Full download datetime (YYYY-MM-DD-HH-MM-SS)
|
||||
- %(download_date)s - Download date only (YYYY-MM-DD)
|
||||
- %(download_year)s - Download year
|
||||
- %(download_month)s - Download month (zero-padded)
|
||||
- %(download_day)s - Download day (zero-padded)
|
||||
- %(domain)s - Domain name
|
||||
|
||||
Subdirectories can be included in the format, e.g.:
|
||||
'%(download_year)s/%(download_datetime)s_%(title)s'
|
||||
"""
|
||||
file_name = 'archives_downloader.yaml'
|
||||
validator = ArchiveDownloaderConfigValidator
|
||||
default_config = dict(
|
||||
version=0,
|
||||
file_name_format='%(download_datetime)s_%(title)s',
|
||||
)
|
||||
|
||||
@property
|
||||
def file_name_format(self) -> str:
|
||||
return self._config['file_name_format']
|
||||
|
||||
|
||||
# Global instance for archive downloader config
|
||||
ARCHIVE_DOWNLOADER_CONFIG: ArchiveDownloaderConfig = ArchiveDownloaderConfig()
|
||||
|
||||
|
||||
def get_archive_downloader_config() -> ArchiveDownloaderConfig:
|
||||
"""Get the global archive downloader config instance."""
|
||||
return ARCHIVE_DOWNLOADER_CONFIG
|
||||
|
||||
|
||||
def format_archive_filename(
|
||||
title: str,
|
||||
domain: str = None,
|
||||
download_date: datetime = None,
|
||||
) -> str:
|
||||
"""Format archive filename using config template.
|
||||
|
||||
This function formats the filename (and optional subdirectory path) for an archive
|
||||
using the file_name_format from archives_downloader.yaml.
|
||||
|
||||
Args:
|
||||
title: Page title (extracted from HTML)
|
||||
domain: Domain name of the archived URL
|
||||
download_date: Date of download (defaults to now)
|
||||
|
||||
Returns:
|
||||
Formatted filename/path (without .html extension, added later)
|
||||
|
||||
Example:
|
||||
format_archive_filename("My Article", "example.com")
|
||||
# With default format: "2025-12-22_My Article"
|
||||
# With "%(download_year)s/%(title)s": "2025/My Article"
|
||||
"""
|
||||
config = get_archive_downloader_config()
|
||||
template = config.file_name_format
|
||||
|
||||
download_date = download_date or now()
|
||||
|
||||
variables = dict(
|
||||
title=escape_file_name(title) if title else 'untitled',
|
||||
domain=domain or '',
|
||||
download_datetime=archive_strftime(download_date),
|
||||
download_date=download_date.strftime('%Y-%m-%d'),
|
||||
download_year=str(download_date.year),
|
||||
download_month=f'{download_date.month:02d}',
|
||||
download_day=f'{download_date.day:02d}',
|
||||
)
|
||||
|
||||
try:
|
||||
return template % variables
|
||||
except KeyError as e:
|
||||
logger.error(f'Invalid variable in archive file_name_format: {e}')
|
||||
# Fallback to default format
|
||||
return f'{archive_strftime(download_date)}_{escape_file_name(title) if title else "untitled"}'
|
||||
|
||||
|
||||
def preview_archive_filename(file_name_format: str) -> str:
|
||||
"""Preview the archive filename using sample data.
|
||||
|
||||
Args:
|
||||
file_name_format: The format template to preview
|
||||
|
||||
Returns:
|
||||
A preview string showing what the filename would look like
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the format is invalid or missing required variables
|
||||
"""
|
||||
if '%(title)s' not in file_name_format:
|
||||
raise RuntimeError('file_name_format must contain %(title)s')
|
||||
|
||||
sample_date = now()
|
||||
variables = dict(
|
||||
title='Example Page Title',
|
||||
domain='example.com',
|
||||
download_datetime=archive_strftime(sample_date),
|
||||
download_date=sample_date.strftime('%Y-%m-%d'),
|
||||
download_year=str(sample_date.year),
|
||||
download_month=f'{sample_date.month:02d}',
|
||||
download_day=f'{sample_date.day:02d}',
|
||||
)
|
||||
|
||||
try:
|
||||
preview = file_name_format % variables
|
||||
return f'{preview}.html'
|
||||
except KeyError as e:
|
||||
raise RuntimeError(f'Invalid variable: {e}')
|
||||
except ValueError as e:
|
||||
raise RuntimeError(f'Invalid format: {e}')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveFiles:
|
||||
"""Every Archive will have some of these files."""
|
||||
|
|
@ -241,38 +378,6 @@ def get_archive_directory() -> pathlib.Path:
|
|||
return archive_directory
|
||||
|
||||
|
||||
def get_domain_directory(url: str) -> pathlib.Path:
|
||||
"""Get the archive directory for a particular domain."""
|
||||
domain = extract_domain(url)
|
||||
directory = get_archive_directory() / domain
|
||||
if directory.is_dir():
|
||||
return directory
|
||||
elif directory.exists():
|
||||
raise FileNotFoundError(f'Domain directory {directory} is already a file')
|
||||
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
return directory
|
||||
|
||||
|
||||
def get_archive_destination(domain_collection: 'Collection') -> pathlib.Path:
|
||||
"""
|
||||
Get the destination directory for archives in a domain collection.
|
||||
|
||||
Args:
|
||||
domain_collection: The domain Collection (kind='domain')
|
||||
|
||||
Returns:
|
||||
Path where archives should be placed. If collection has a directory,
|
||||
returns that directory. Otherwise returns the default archives/<domain>/ path.
|
||||
"""
|
||||
if domain_collection.directory:
|
||||
# Restricted collection - use collection directory
|
||||
return get_media_directory() / domain_collection.directory
|
||||
else:
|
||||
# Unrestricted collection - use default archive/<domain>/ path
|
||||
return get_archive_directory() / domain_collection.name
|
||||
|
||||
|
||||
# File names include domain and datetime.
|
||||
MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH = 200
|
||||
|
||||
|
|
@ -285,22 +390,35 @@ def get_new_archive_files(url: str, title: Optional[str], destination: pathlib.P
|
|||
title: Title for the archive files
|
||||
destination: Optional directory to save files to. If provided, files go here instead of archive/<domain>
|
||||
"""
|
||||
domain = extract_domain(url)
|
||||
if destination:
|
||||
directory = destination
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
else:
|
||||
directory = get_domain_directory(url)
|
||||
# Datetime is valid in Linux and Windows.
|
||||
dt = archive_strftime(now())
|
||||
# Fallback to archive/<domain>/ when no destination provided
|
||||
directory = get_archive_directory() / domain
|
||||
directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
title = escape_file_name(title or 'NA')
|
||||
# Use the configured file format template
|
||||
title = title or 'NA'
|
||||
title = title[:MAXIMUM_ARCHIVE_FILE_CHARACTER_LENGTH]
|
||||
prefix = f'{dt}_{title}'
|
||||
singlefile_path = directory / f'{prefix}.html'
|
||||
readability_path = directory / f'{prefix}.readability.html'
|
||||
readability_txt_path = directory / f'{prefix}.readability.txt'
|
||||
readability_json_path = directory / f'{prefix}.readability.json'
|
||||
screenshot_path = directory / f'{prefix}.png'
|
||||
prefix = format_archive_filename(title, domain=domain)
|
||||
|
||||
# Handle subdirectories in the format (e.g., "%(download_year)s/%(title)s")
|
||||
if '/' in prefix:
|
||||
# Prefix includes subdirectories, create them relative to base directory
|
||||
full_path = directory / prefix
|
||||
full_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
singlefile_path = full_path.with_suffix('.html')
|
||||
readability_path = full_path.parent / f'{full_path.name}.readability.html'
|
||||
readability_txt_path = full_path.parent / f'{full_path.name}.readability.txt'
|
||||
readability_json_path = full_path.parent / f'{full_path.name}.readability.json'
|
||||
screenshot_path = full_path.with_suffix('.png')
|
||||
else:
|
||||
singlefile_path = directory / f'{prefix}.html'
|
||||
readability_path = directory / f'{prefix}.readability.html'
|
||||
readability_txt_path = directory / f'{prefix}.readability.txt'
|
||||
readability_json_path = directory / f'{prefix}.readability.json'
|
||||
screenshot_path = directory / f'{prefix}.png'
|
||||
|
||||
paths = (singlefile_path, readability_path, readability_txt_path, readability_json_path, screenshot_path)
|
||||
|
||||
|
|
|
|||
|
|
@ -30,3 +30,8 @@ class ArchiveSearchRequest:
|
|||
class ArchiveSearchResponse:
|
||||
file_groups: List[ArchiveDict]
|
||||
totals: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveFileFormatRequest:
|
||||
archive_file_format: str
|
||||
|
|
|
|||
|
|
@ -406,3 +406,42 @@ async def test_archive_generate_screenshot(test_session, async_client, archive_f
|
|||
request, response = await async_client.post(f'/api/archive/{archive_wrol.id}/generate_screenshot')
|
||||
assert response.status_code == HTTPStatus.FORBIDDEN
|
||||
await wrol_mode_fixture(False)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_file_format_preview(async_client):
|
||||
"""Test the archive file format preview endpoint."""
|
||||
# Test valid format with download_datetime (default format)
|
||||
data = {'archive_file_format': '%(download_datetime)s_%(title)s'}
|
||||
request, response = await async_client.post('/api/archive/file_format', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert 'preview' in response.json
|
||||
assert response.json['preview'].endswith('.html')
|
||||
assert 'Example Page Title' in response.json['preview']
|
||||
# Verify datetime format YYYY-MM-DD-HH-MM-SS
|
||||
import re
|
||||
assert re.search(r'\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}', response.json['preview'])
|
||||
|
||||
# Test valid format with download_date (date only)
|
||||
data = {'archive_file_format': '%(download_date)s_%(title)s'}
|
||||
request, response = await async_client.post('/api/archive/file_format', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert re.search(r'\d{4}-\d{2}-\d{2}_', response.json['preview'])
|
||||
|
||||
# Test format with year subdirectory
|
||||
data = {'archive_file_format': '%(download_year)s/%(download_datetime)s_%(title)s'}
|
||||
request, response = await async_client.post('/api/archive/file_format', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert '/' in response.json['preview'] # Contains subdirectory
|
||||
|
||||
# Test invalid format - missing title
|
||||
data = {'archive_file_format': '%(download_date)s'}
|
||||
request, response = await async_client.post('/api/archive/file_format', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert 'error' in response.json
|
||||
|
||||
# Test invalid format - unknown variable
|
||||
data = {'archive_file_format': '%(invalid_var)s_%(title)s'}
|
||||
request, response = await async_client.post('/api/archive/file_format', content=json.dumps(data))
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert 'error' in response.json
|
||||
|
|
|
|||
|
|
@ -70,10 +70,15 @@ async def test_tag_unrestricted_domain_collection(async_client, test_session, ta
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_domain_collection_moves_files(
|
||||
test_session, archive_factory, tag_factory, test_directory, make_files_structure, archive_directory,
|
||||
await_switches,
|
||||
async_client, test_session, archive_factory, tag_factory, test_directory, make_files_structure,
|
||||
archive_directory, await_switches, test_wrolpi_config,
|
||||
):
|
||||
"""Tagging domain collection with directory moves archive files."""
|
||||
# Configure template to include tag in path
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
config = get_wrolpi_config()
|
||||
config.update({'archive_destination': 'archive/%(domain_tag)s/%(domain)s'})
|
||||
|
||||
# Create domain collection with directory
|
||||
# Use archive_directory fixture to match where archive_factory creates files
|
||||
domain_dir = archive_directory / 'test.com'
|
||||
|
|
@ -254,48 +259,6 @@ async def test_get_or_create_domain_collection_with_directory(test_session, test
|
|||
assert collection.directory == domain_dir
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_archive_destination_with_directory(test_session, test_directory):
|
||||
"""get_archive_destination returns collection directory when set."""
|
||||
from modules.archive.lib import get_archive_destination
|
||||
from wrolpi.common import get_media_directory
|
||||
|
||||
domain_dir = get_media_directory() / 'archives' / 'tagged' / 'example.com'
|
||||
domain_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
collection = Collection(
|
||||
name='example.com',
|
||||
kind='domain',
|
||||
directory=domain_dir
|
||||
)
|
||||
test_session.add(collection)
|
||||
test_session.commit()
|
||||
|
||||
destination = get_archive_destination(collection)
|
||||
|
||||
assert destination == domain_dir
|
||||
assert destination.is_dir()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_archive_destination_unrestricted(test_session):
|
||||
"""get_archive_destination returns default path for unrestricted collection."""
|
||||
from modules.archive.lib import get_archive_destination, get_archive_directory
|
||||
|
||||
collection = Collection(
|
||||
name='example.com',
|
||||
kind='domain',
|
||||
directory=None # Unrestricted
|
||||
)
|
||||
test_session.add(collection)
|
||||
test_session.commit()
|
||||
|
||||
destination = get_archive_destination(collection)
|
||||
expected = get_archive_directory() / 'example.com'
|
||||
|
||||
assert destination == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tag_domain_without_directory_saves_config(test_session, test_directory, tag_factory, await_switches):
|
||||
"""Tagging domain collection without directory should still save config."""
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ from PIL import Image
|
|||
from pytz import utc
|
||||
|
||||
from modules.archive import lib
|
||||
from modules.archive.lib import ArchiveDownloaderConfigValidator
|
||||
from modules.archive.lib import format_archive_filename
|
||||
from modules.archive.lib import get_archive_downloader_config
|
||||
from modules.archive.lib import get_or_create_domain_collection, get_new_archive_files, delete_archives, \
|
||||
model_archive_result, get_domains
|
||||
from modules.archive.models import Archive
|
||||
|
|
@ -373,7 +376,7 @@ def test_get_new_archive_files_length(test_directory, fake_now):
|
|||
|
||||
|
||||
@skip_circleci
|
||||
def test_get_new_archive_files(fake_now):
|
||||
def test_get_new_archive_files(test_directory, fake_now):
|
||||
"""Archive files have a specific format, so they are sorted by datetime, and are near each other."""
|
||||
fake_now(datetime(2001, 1, 1))
|
||||
archive_files = get_new_archive_files('https://example.com/two', None)
|
||||
|
|
@ -405,16 +408,50 @@ def test_get_new_archive_files_with_destination(test_directory, fake_now):
|
|||
|
||||
# Files should be in the custom destination, not archive/example.com
|
||||
assert str(archive_files.singlefile).endswith('archive/News/custom-domain.com/2001-01-01-00-00-00_Title.html')
|
||||
assert str(archive_files.readability).endswith('archive/News/custom-domain.com/2001-01-01-00-00-00_Title.readability.html')
|
||||
assert str(archive_files.readability_txt).endswith('archive/News/custom-domain.com/2001-01-01-00-00-00_Title.readability.txt')
|
||||
assert str(archive_files.readability).endswith(
|
||||
'archive/News/custom-domain.com/2001-01-01-00-00-00_Title.readability.html')
|
||||
assert str(archive_files.readability_txt).endswith(
|
||||
'archive/News/custom-domain.com/2001-01-01-00-00-00_Title.readability.txt')
|
||||
assert str(archive_files.readability_json).endswith(
|
||||
'archive/News/custom-domain.com/2001-01-01-00-00-00_Title.readability.json')
|
||||
assert str(archive_files.screenshot).endswith('archive/News/custom-domain.com/2001-01-01-00-00-00_Title.png')
|
||||
|
||||
|
||||
@skip_circleci
|
||||
def test_get_new_archive_files_with_subdirectory_format(test_directory, fake_now):
|
||||
"""Archive files use subdirectories when the config format contains subdirectories."""
|
||||
from modules.archive.lib import get_archive_downloader_config
|
||||
|
||||
fake_now(datetime(2001, 1, 1))
|
||||
|
||||
# Configure a file format with subdirectory
|
||||
config = get_archive_downloader_config()
|
||||
original_format = config._config['file_name_format']
|
||||
config._config['file_name_format'] = '%(download_year)s/%(download_datetime)s_%(title)s'
|
||||
|
||||
try:
|
||||
archive_files = get_new_archive_files('https://example.com/page', 'My Article')
|
||||
|
||||
# Files should be created in a year subdirectory
|
||||
assert str(archive_files.singlefile).endswith('archive/example.com/2001/2001-01-01-00-00-00_My Article.html')
|
||||
assert str(archive_files.readability).endswith(
|
||||
'archive/example.com/2001/2001-01-01-00-00-00_My Article.readability.html')
|
||||
assert str(archive_files.readability_txt).endswith(
|
||||
'archive/example.com/2001/2001-01-01-00-00-00_My Article.readability.txt')
|
||||
assert str(archive_files.readability_json).endswith(
|
||||
'archive/example.com/2001/2001-01-01-00-00-00_My Article.readability.json')
|
||||
assert str(archive_files.screenshot).endswith('archive/example.com/2001/2001-01-01-00-00-00_My Article.png')
|
||||
|
||||
# The subdirectory should exist
|
||||
subdir = test_directory / 'archive/example.com/2001'
|
||||
assert subdir.is_dir()
|
||||
finally:
|
||||
config._config['file_name_format'] = original_format
|
||||
|
||||
|
||||
@skip_circleci
|
||||
@pytest.mark.asyncio
|
||||
async def test_title_in_filename(test_session, fake_now, test_directory, image_bytes_factory):
|
||||
async def test_title_in_filename(async_client, test_session, fake_now, test_directory, image_bytes_factory):
|
||||
"""
|
||||
The Archive files have the title in the path.
|
||||
"""
|
||||
|
|
@ -457,6 +494,8 @@ async def test_title_in_filename(test_session, fake_now, test_directory, image_b
|
|||
assert str(archive2.screenshot_path.relative_to(test_directory)) == \
|
||||
'archive/example.com/2000-01-01-00-00-00_NA.png'
|
||||
|
||||
# Test with malformed HTML - <title> not properly closed with </title>
|
||||
# The parser includes </html></html> in the title, which gets escaped
|
||||
singlefile = '<html>\ntest single-file\nジにてこちら\n<title>dangerous ;\\//_title</html></html>'
|
||||
readability = dict(
|
||||
# No Title from Readability.
|
||||
|
|
@ -467,16 +506,18 @@ async def test_title_in_filename(test_session, fake_now, test_directory, image_b
|
|||
|
||||
archive3 = await model_archive_result('https://example.com', singlefile, readability, screenshot)
|
||||
|
||||
# Title includes </html></html> because the <title> tag is malformed (no </title>)
|
||||
# Slashes are escaped: </html> -> <⧸html>
|
||||
assert str(archive3.singlefile_path.relative_to(test_directory)) == \
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title.html'
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title⧸html⧸html.html'
|
||||
assert str(archive3.readability_path.relative_to(test_directory)) == \
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title.readability.html'
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title⧸html⧸html.readability.html'
|
||||
assert str(archive3.readability_txt_path.relative_to(test_directory)) == \
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title.readability.txt'
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title⧸html⧸html.readability.txt'
|
||||
assert str(archive3.readability_json_path.relative_to(test_directory)) == \
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title.readability.json'
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title⧸html⧸html.readability.json'
|
||||
assert str(archive3.screenshot_path.relative_to(test_directory)) == \
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title.png'
|
||||
'archive/example.com/2000-01-01-00-00-00_dangerous ;⧸⧸_title⧸html⧸html.png'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -880,7 +921,6 @@ async def test_get_custom_archive_directory(async_client, test_directory, test_w
|
|||
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')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
|
@ -1102,7 +1142,7 @@ def test_link_domain_and_downloads(test_session, test_download_manager):
|
|||
@skip_circleci
|
||||
@pytest.mark.asyncio
|
||||
async def test_archive_download_uses_domain_collection_directory(async_client, test_session, test_directory, fake_now,
|
||||
monkeypatch):
|
||||
monkeypatch):
|
||||
"""
|
||||
Test that archive downloads use the domain collection's directory when no explicit destination is set.
|
||||
|
||||
|
|
@ -1218,3 +1258,67 @@ async def test_archive_download_explicit_destination_overrides_domain_collection
|
|||
f"Archive should be in explicit destination {explicit_dir}, but was in {archive.singlefile_path.parent}"
|
||||
assert not str(archive.singlefile_path).startswith(str(collection_dir)), \
|
||||
f"Archive should NOT be in collection directory {collection_dir}"
|
||||
|
||||
|
||||
def test_archive_downloader_config(test_directory):
|
||||
"""ArchiveDownloaderConfig loads and provides file_name_format."""
|
||||
config = get_archive_downloader_config()
|
||||
|
||||
# Default format should contain %(title)s
|
||||
assert '%(title)s' in config.file_name_format
|
||||
assert config.file_name_format == '%(download_datetime)s_%(title)s'
|
||||
|
||||
|
||||
def test_archive_downloader_config_validation():
|
||||
"""ArchiveDownloaderConfig validator rejects invalid formats."""
|
||||
# Valid format
|
||||
ArchiveDownloaderConfigValidator(file_name_format='%(download_date)s_%(title)s')
|
||||
|
||||
# Invalid format (missing %(title)s)
|
||||
with pytest.raises(ValueError, match='must contain'):
|
||||
ArchiveDownloaderConfigValidator(file_name_format='%(download_date)s_no_title')
|
||||
|
||||
|
||||
def test_format_archive_filename_default(test_directory, fake_now):
|
||||
"""format_archive_filename uses default format."""
|
||||
result = format_archive_filename('My Article')
|
||||
|
||||
# Default format: %(download_datetime)s_%(title)s
|
||||
assert result == '2000-01-01-00-00-00_My Article'
|
||||
|
||||
|
||||
def test_format_archive_filename_with_domain(test_directory, fake_now):
|
||||
"""format_archive_filename includes domain variable."""
|
||||
# Temporarily change the format to include domain
|
||||
config = get_archive_downloader_config()
|
||||
original_format = config._config['file_name_format']
|
||||
config._config['file_name_format'] = '%(domain)s_%(title)s'
|
||||
|
||||
try:
|
||||
result = format_archive_filename('My Article', domain='example.com')
|
||||
assert result == 'example.com_My Article'
|
||||
finally:
|
||||
config._config['file_name_format'] = original_format
|
||||
|
||||
|
||||
def test_format_archive_filename_with_year_subdirectory(test_directory, fake_now):
|
||||
"""format_archive_filename can include subdirectories in format."""
|
||||
config = get_archive_downloader_config()
|
||||
original_format = config._config['file_name_format']
|
||||
config._config['file_name_format'] = '%(download_year)s/%(download_date)s_%(title)s'
|
||||
|
||||
try:
|
||||
result = format_archive_filename('My Article')
|
||||
# Should include year subdirectory
|
||||
assert result == '2000/2000-01-01_My Article'
|
||||
finally:
|
||||
config._config['file_name_format'] = original_format
|
||||
|
||||
|
||||
def test_format_archive_filename_escapes_special_chars(test_directory, fake_now):
|
||||
"""format_archive_filename escapes special characters in title."""
|
||||
result = format_archive_filename('My/Article:With*Special?Chars')
|
||||
|
||||
# Special characters should be escaped
|
||||
assert '/' not in result.split('/')[-1] # No slashes in filename part
|
||||
assert '2000-01-01' in result
|
||||
|
|
|
|||
|
|
@ -837,6 +837,10 @@ def format_videos_destination(channel_name: str = None, channel_tag: str = None,
|
|||
-> pathlib.Path:
|
||||
"""Return the directory where Videos should be downloaded according to the WROLPi Config.
|
||||
|
||||
Note: When you have a Collection object, prefer using Collection.format_destination()
|
||||
or Collection.get_or_set_directory() instead, as they provide unified behavior
|
||||
across both channel and domain collections.
|
||||
|
||||
@warning: Directory may or may not exist."""
|
||||
videos_destination = get_wrolpi_config().videos_destination
|
||||
|
||||
|
|
|
|||
|
|
@ -824,24 +824,88 @@ class Collection(ModelHelper, Base):
|
|||
|
||||
For domain collections (kind == 'domain'), use the configured archive directory:
|
||||
archive/<Tag>/<Domain Name>
|
||||
|
||||
@deprecated: Use format_destination() instead for new code.
|
||||
"""
|
||||
# Delegate to format_destination for consistency
|
||||
return self.format_destination(tag_name)
|
||||
|
||||
def format_destination(self, tag_name: Optional[str] = None) -> pathlib.Path:
|
||||
"""Format the collection's destination path using config template.
|
||||
|
||||
Uses videos_destination for kind='channel', archive_destination for kind='domain'.
|
||||
Supports unified variables that work for both kinds.
|
||||
|
||||
Args:
|
||||
tag_name: Optional tag name to include in the path
|
||||
|
||||
Returns:
|
||||
Absolute path where this collection's files should be stored
|
||||
"""
|
||||
from wrolpi.common import get_wrolpi_config, escape_file_name
|
||||
|
||||
config = get_wrolpi_config()
|
||||
|
||||
if self.kind == 'channel':
|
||||
# Local import to avoid circular import: collections -> videos -> collections
|
||||
from modules.videos.lib import format_videos_destination
|
||||
return format_videos_destination(self.name, tag_name, None)
|
||||
template = config.videos_destination
|
||||
elif self.kind == 'domain':
|
||||
# Use configured archive directory for domain collections
|
||||
# Local import to avoid circular import: collections -> archive -> collections
|
||||
from modules.archive.lib import get_archive_directory
|
||||
base = get_archive_directory()
|
||||
if tag_name:
|
||||
return base / tag_name / self.name
|
||||
return base / self.name
|
||||
# Default behavior: place under media root by kind/tag/name
|
||||
base = get_media_directory() / self.kind
|
||||
if tag_name:
|
||||
return base / tag_name / self.name
|
||||
return base / self.name
|
||||
template = config.archive_destination
|
||||
else:
|
||||
template = '%(kind)s/%(tag)s/%(name)s'
|
||||
|
||||
escaped_name = escape_file_name(self.name) if self.name else ''
|
||||
|
||||
variables = dict(
|
||||
# Unified variables
|
||||
name=escaped_name,
|
||||
tag=tag_name or '',
|
||||
kind=self.kind or '',
|
||||
# Backward compatibility aliases for videos
|
||||
channel_name=escaped_name,
|
||||
channel_tag=tag_name or '',
|
||||
# Backward compatibility aliases for archives
|
||||
domain=escaped_name,
|
||||
domain_tag=tag_name or '',
|
||||
)
|
||||
|
||||
destination = template % variables
|
||||
return get_media_directory() / destination.lstrip('/')
|
||||
|
||||
def get_or_set_directory(self, session: Session, tag_name: Optional[str] = None) -> pathlib.Path:
|
||||
"""Get collection's directory, or format and save it on first use.
|
||||
|
||||
- If collection has directory: return it
|
||||
- If no directory: format from template, save to collection, return it
|
||||
|
||||
This ensures all files for a collection go to the same place, even if
|
||||
the template config changes later.
|
||||
|
||||
Args:
|
||||
session: Database session for committing changes
|
||||
tag_name: Optional tag name to use when formatting (defaults to collection's tag)
|
||||
|
||||
Returns:
|
||||
Absolute path to the collection's directory
|
||||
"""
|
||||
if self.directory:
|
||||
return get_media_directory() / self.directory
|
||||
|
||||
# First use - format and save
|
||||
tag_name = tag_name or (self.tag.name if self.tag else None)
|
||||
destination = self.format_destination(tag_name)
|
||||
|
||||
self.directory = get_relative_to_media_directory(destination)
|
||||
session.commit()
|
||||
|
||||
# Trigger config save for persistence
|
||||
if self.kind == 'domain':
|
||||
from modules.archive.lib import save_domains_config
|
||||
save_domains_config.activate_switch()
|
||||
elif self.kind == 'channel':
|
||||
from modules.videos.lib import save_channels_config
|
||||
save_channels_config.activate_switch()
|
||||
|
||||
return destination
|
||||
|
||||
async def move_collection(self, directory: pathlib.Path, session: Session, send_events: bool = False):
|
||||
"""Move all files under this Collection's directory to a new directory.
|
||||
|
|
|
|||
|
|
@ -141,9 +141,14 @@ class TestCollectionTagInfoAPI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tag_info_suggests_directory(
|
||||
self, async_client, test_session, test_directory
|
||||
self, async_client, test_session, test_directory, test_wrolpi_config
|
||||
):
|
||||
"""Test that tag_info endpoint suggests a directory for a domain collection."""
|
||||
# Configure template to include tag
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
config = get_wrolpi_config()
|
||||
config.update({'archive_destination': 'archive/%(domain_tag)s/%(domain)s'})
|
||||
|
||||
# Create a domain collection
|
||||
collection = Collection(
|
||||
name='example.com',
|
||||
|
|
@ -174,9 +179,14 @@ class TestCollectionTagInfoAPI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tag_info_detects_domain_conflict(
|
||||
self, async_client, test_session, test_directory
|
||||
self, async_client, test_session, test_directory, test_wrolpi_config
|
||||
):
|
||||
"""Test that tag_info detects conflicts with existing domain collections."""
|
||||
# Configure template to include tag
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
config = get_wrolpi_config()
|
||||
config.update({'archive_destination': 'archive/%(domain_tag)s/%(domain)s'})
|
||||
|
||||
# Create two domain collections
|
||||
collection1 = Collection(
|
||||
name='example.com',
|
||||
|
|
@ -211,9 +221,14 @@ class TestCollectionTagInfoAPI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tag_info_allows_channel_domain_same_directory(
|
||||
self, async_client, test_session, test_directory, channel_factory
|
||||
self, async_client, test_session, test_directory, channel_factory, test_wrolpi_config
|
||||
):
|
||||
"""Test that channel and domain collections can share directories."""
|
||||
# Configure template to include tag
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
config = get_wrolpi_config()
|
||||
config.update({'archive_destination': 'archive/%(domain_tag)s/%(domain)s'})
|
||||
|
||||
# Create a channel collection
|
||||
channel = channel_factory(name='test', directory=test_directory / 'videos' / 'WROL' / 'test')
|
||||
test_session.flush()
|
||||
|
|
@ -242,9 +257,14 @@ class TestCollectionTagInfoAPI:
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_tag_info_detects_exact_directory_conflict(
|
||||
self, async_client, test_session, test_directory
|
||||
self, async_client, test_session, test_directory, test_wrolpi_config
|
||||
):
|
||||
"""Test that tag_info detects conflicts when suggested directory exactly matches existing collection."""
|
||||
# Configure template to include tag
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
config = get_wrolpi_config()
|
||||
config.update({'archive_destination': 'archive/%(domain_tag)s/%(domain)s'})
|
||||
|
||||
# Create two domain collections with different names but where one's suggested directory
|
||||
# would conflict with the other's existing directory
|
||||
# collection1 is already in the "WROL" tagged location
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import pytest
|
|||
|
||||
from wrolpi.collections import Collection
|
||||
from wrolpi.collections.errors import UnknownCollection
|
||||
from wrolpi.common import get_wrolpi_config
|
||||
|
||||
|
||||
def test_collection_get_by_id(test_session, test_directory):
|
||||
|
|
@ -34,3 +35,109 @@ def test_collection_find_by_id(test_session, test_directory):
|
|||
# Non-existent ID should raise UnknownCollection
|
||||
with pytest.raises(UnknownCollection):
|
||||
Collection.find_by_id(test_session, 99999)
|
||||
|
||||
|
||||
def test_collection_format_destination_channel(test_session, test_directory):
|
||||
"""Collection.format_destination formats path for channel collections."""
|
||||
collection = Collection(name='My Channel', kind='channel')
|
||||
test_session.add(collection)
|
||||
test_session.flush()
|
||||
|
||||
destination = collection.format_destination()
|
||||
# Should use videos_destination template: videos/%(channel_tag)s/%(channel_name)s
|
||||
assert destination == test_directory / 'videos' / 'My Channel'
|
||||
|
||||
# With tag
|
||||
destination_with_tag = collection.format_destination(tag_name='News')
|
||||
assert destination_with_tag == test_directory / 'videos' / 'News' / 'My Channel'
|
||||
|
||||
|
||||
def test_collection_format_destination_domain(test_session, test_directory):
|
||||
"""Collection.format_destination formats path for domain collections."""
|
||||
collection = Collection(name='example.com', kind='domain')
|
||||
test_session.add(collection)
|
||||
test_session.flush()
|
||||
|
||||
destination = collection.format_destination()
|
||||
# Should use archive_destination template: archive/%(domain)s
|
||||
assert destination == test_directory / 'archive' / 'example.com'
|
||||
|
||||
# With tag - default template doesn't include %(domain_tag)s, so tag has no effect
|
||||
destination_with_tag = collection.format_destination(tag_name='Tech')
|
||||
# Default archive_destination is 'archive/%(domain)s' (no tag placeholder)
|
||||
assert destination_with_tag == test_directory / 'archive' / 'example.com'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_format_destination_domain_with_tag_template(async_client, test_session, test_directory,
|
||||
test_wrolpi_config):
|
||||
"""Collection.format_destination uses tag when template includes %(domain_tag)s."""
|
||||
# Update config to include tag in template
|
||||
config = get_wrolpi_config()
|
||||
config.update({'archive_destination': 'archive/%(domain_tag)s/%(domain)s'})
|
||||
|
||||
collection = Collection(name='example.com', kind='domain')
|
||||
test_session.add(collection)
|
||||
test_session.flush()
|
||||
|
||||
# With tag - template now includes %(domain_tag)s
|
||||
destination_with_tag = collection.format_destination(tag_name='Tech')
|
||||
assert destination_with_tag == test_directory / 'archive' / 'Tech' / 'example.com'
|
||||
|
||||
# Without tag - empty tag component
|
||||
destination_no_tag = collection.format_destination()
|
||||
assert destination_no_tag == test_directory / 'archive' / 'example.com'
|
||||
|
||||
|
||||
def test_collection_get_or_set_directory_existing(test_session, test_directory):
|
||||
"""Collection.get_or_set_directory returns existing directory without modifying it."""
|
||||
existing_dir = test_directory / 'existing/path'
|
||||
collection = Collection(name='example.com', kind='domain', directory=existing_dir)
|
||||
test_session.add(collection)
|
||||
test_session.commit()
|
||||
|
||||
result = collection.get_or_set_directory(test_session)
|
||||
|
||||
assert result == existing_dir
|
||||
# Directory should not have changed
|
||||
assert collection.directory == existing_dir
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_get_or_set_directory_new(async_client, test_session, test_directory):
|
||||
"""Collection.get_or_set_directory formats and saves directory on first use."""
|
||||
collection = Collection(name='example.com', kind='domain')
|
||||
test_session.add(collection)
|
||||
test_session.commit()
|
||||
|
||||
# Initially no directory
|
||||
assert collection.directory is None
|
||||
|
||||
result = collection.get_or_set_directory(test_session)
|
||||
|
||||
# Should have set the directory
|
||||
assert collection.directory is not None
|
||||
assert result == test_directory / 'archive' / 'example.com'
|
||||
# Directory should end with the relative path structure
|
||||
assert str(collection.directory).endswith('archive/example.com')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_collection_get_or_set_directory_with_tag(async_client, test_session, test_directory, tag_factory,
|
||||
test_wrolpi_config):
|
||||
"""Collection.get_or_set_directory uses collection's tag when formatting."""
|
||||
# Update config to include tag in template
|
||||
config = get_wrolpi_config()
|
||||
config.update({'archive_destination': 'archive/%(domain_tag)s/%(domain)s'})
|
||||
|
||||
tag = await tag_factory('News')
|
||||
collection = Collection(name='example.com', kind='domain', tag=tag)
|
||||
test_session.add(collection)
|
||||
test_session.commit()
|
||||
|
||||
result = collection.get_or_set_directory(test_session)
|
||||
|
||||
# Should include tag in path (config has %(domain_tag)s)
|
||||
assert result == test_directory / 'archive' / 'News' / 'example.com'
|
||||
# Directory should end with the relative path structure
|
||||
assert str(collection.directory).endswith('archive/News/example.com')
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ from itertools import islice, filterfalse, tee
|
|||
from multiprocessing.managers import DictProxy
|
||||
from pathlib import Path
|
||||
from types import GeneratorType
|
||||
from typing import Optional, List
|
||||
from typing import Optional, List, Any, AsyncGenerator
|
||||
from typing import Union, Callable, Tuple, Dict, Iterable, Generator, Any, Set
|
||||
from urllib.parse import urlparse, urlunsplit
|
||||
|
||||
|
|
@ -764,9 +764,11 @@ def get_all_configs() -> Dict[str, ConfigFile]:
|
|||
if download_manager_config := get_download_manager_config():
|
||||
all_configs[download_manager_config.file_name] = download_manager_config
|
||||
|
||||
from modules.archive.lib import get_domains_config
|
||||
from modules.archive.lib import get_domains_config, get_archive_downloader_config
|
||||
if domains_config := get_domains_config():
|
||||
all_configs[domains_config.file_name] = domains_config
|
||||
if archive_downloader_config := get_archive_downloader_config():
|
||||
all_configs[archive_downloader_config.file_name] = archive_downloader_config
|
||||
|
||||
return all_configs
|
||||
|
||||
|
|
@ -1460,7 +1462,7 @@ def recursive_map(obj: Any, func: callable):
|
|||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def aiohttp_session(timeout: int = None) -> ClientSession:
|
||||
async def aiohttp_session(timeout: int = None) -> AsyncGenerator[ClientSession, Any]:
|
||||
"""Convenience function because aiohttp timeout cannot be None."""
|
||||
if timeout:
|
||||
timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
|
||||
|
|
@ -1472,7 +1474,8 @@ async def aiohttp_session(timeout: int = None) -> ClientSession:
|
|||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def aiohttp_post(url: str, json_, timeout: int = None, headers: dict = None) -> ClientResponse:
|
||||
async def aiohttp_post(url: str, json_, timeout: int = None, headers: dict = None) -> AsyncGenerator[
|
||||
ClientResponse, Any]:
|
||||
"""Perform an async aiohttp POST request. Yield the response in a session."""
|
||||
headers = headers or DEFAULT_HTTP_HEADERS
|
||||
async with aiohttp_session(timeout) as session:
|
||||
|
|
@ -1481,7 +1484,7 @@ async def aiohttp_post(url: str, json_, timeout: int = None, headers: dict = Non
|
|||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def aiohttp_get(url: str, timeout: int = None, headers: dict = None) -> ClientResponse:
|
||||
async def aiohttp_get(url: str, timeout: int = None, headers: dict = None) -> AsyncGenerator[ClientResponse, Any]:
|
||||
"""Perform an async aiohttp GET request. Yield the response in a session."""
|
||||
headers = headers or DEFAULT_HTTP_HEADERS
|
||||
async with aiohttp_session(timeout) as session:
|
||||
|
|
@ -1490,7 +1493,7 @@ async def aiohttp_get(url: str, timeout: int = None, headers: dict = None) -> Cl
|
|||
|
||||
|
||||
@contextlib.asynccontextmanager
|
||||
async def aiohttp_head(url: str, timeout: int = None, headers: dict = None) -> ClientResponse:
|
||||
async def aiohttp_head(url: str, timeout: int = None, headers: dict = None) -> AsyncGenerator[ClientResponse, Any]:
|
||||
"""Perform an async aiohttp HEAD request. Yield the response in a session."""
|
||||
headers = headers or DEFAULT_HTTP_HEADERS
|
||||
async with aiohttp_session(timeout) as session:
|
||||
|
|
@ -2266,6 +2269,11 @@ def create_empty_config_files() -> list[str]:
|
|||
get_videos_downloader_config().save(overwrite=True)
|
||||
created.append(get_videos_downloader_config().get_file().name)
|
||||
|
||||
from modules.archive.lib import get_archive_downloader_config
|
||||
if not get_archive_downloader_config().get_file().is_file():
|
||||
get_archive_downloader_config().save(overwrite=True)
|
||||
created.append(get_archive_downloader_config().get_file().name)
|
||||
|
||||
if not get_inventories_config().get_file().is_file():
|
||||
from modules.inventory.models import Inventory
|
||||
if session.query(Inventory).count() == 0:
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ def attach_shared_contexts(app: Sanic):
|
|||
app.shared_ctx.download_manager_config = manager.dict()
|
||||
app.shared_ctx.videos_downloader_config = manager.dict()
|
||||
app.shared_ctx.domains_config = manager.dict()
|
||||
app.shared_ctx.archive_downloader_config = manager.dict()
|
||||
# Shared dicts.
|
||||
app.shared_ctx.refresh = manager.dict()
|
||||
app.shared_ctx.uploaded_files = manager.dict()
|
||||
|
|
@ -90,6 +91,7 @@ def reset_shared_contexts(app: Sanic):
|
|||
app.shared_ctx.download_manager_config.clear()
|
||||
app.shared_ctx.videos_downloader_config.clear()
|
||||
app.shared_ctx.domains_config.clear()
|
||||
app.shared_ctx.archive_downloader_config.clear()
|
||||
# Shared dicts.
|
||||
app.shared_ctx.refresh.clear()
|
||||
app.shared_ctx.uploaded_files.clear()
|
||||
|
|
@ -209,3 +211,9 @@ def initialize_configs_contexts(app: Sanic):
|
|||
domains_config.initialize(app.shared_ctx.domains_config)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize in-memory domains config: {e}')
|
||||
|
||||
try:
|
||||
from modules.archive.lib import ARCHIVE_DOWNLOADER_CONFIG
|
||||
ARCHIVE_DOWNLOADER_CONFIG.initialize(app.shared_ctx.archive_downloader_config)
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to initialize in-memory archive downloader config: {e}')
|
||||
|
|
|
|||
|
|
@ -900,7 +900,8 @@ count = 0
|
|||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_error(test_session, test_directory, make_files_structure, video_bytes, singlefile_contents_factory):
|
||||
async def test_move_error(async_client, test_session, test_directory, make_files_structure, video_bytes,
|
||||
singlefile_contents_factory):
|
||||
"""Files are restored when a move fails."""
|
||||
make_files_structure({
|
||||
'foo/bar/video.mp4': video_bytes,
|
||||
|
|
|
|||
|
|
@ -1067,6 +1067,7 @@ async def test_initialize_config_files(async_client, test_session, test_director
|
|||
test_download_manager, test_downloader):
|
||||
"""Config files should only be created if they are missing, and they will not overwrite existing entries in the DB."""
|
||||
from modules.videos.lib import get_videos_downloader_config, get_channels_config
|
||||
from modules.archive.lib import get_archive_downloader_config
|
||||
from wrolpi.tags import get_tags_config
|
||||
from wrolpi.downloader import get_download_manager_config
|
||||
from modules.inventory.common import get_inventories_config
|
||||
|
|
@ -1079,11 +1080,13 @@ async def test_initialize_config_files(async_client, test_session, test_director
|
|||
# Write some config files, they should not be re-created.
|
||||
get_wrolpi_config().get_file().write_text('wrolpi')
|
||||
get_videos_downloader_config().get_file().write_text('video downloader')
|
||||
get_archive_downloader_config().get_file().write_text('archive downloader')
|
||||
|
||||
assert common.create_empty_config_files() == [], 'No configs should have been created.'
|
||||
# Config files were not overwritten with real configs.
|
||||
assert get_wrolpi_config().get_file().read_text() == 'wrolpi'
|
||||
assert get_videos_downloader_config().get_file().read_text() == 'video downloader'
|
||||
assert get_archive_downloader_config().get_file().read_text() == 'archive downloader'
|
||||
|
||||
# WROLPi config can be created because it is now missing.
|
||||
get_wrolpi_config().get_file().unlink()
|
||||
|
|
@ -1096,6 +1099,12 @@ async def test_initialize_config_files(async_client, test_session, test_director
|
|||
'Videos Downloader config should have been created.'
|
||||
assert get_videos_downloader_config().is_valid()
|
||||
|
||||
# Archive Downloader config can be created because it is now missing.
|
||||
get_archive_downloader_config().get_file().unlink()
|
||||
assert common.create_empty_config_files() == ['archives_downloader.yaml'], \
|
||||
'Archive Downloader config should have been created.'
|
||||
assert get_archive_downloader_config().is_valid()
|
||||
|
||||
# Channels config file does not exist, no conflicting Channels so the file is created.
|
||||
simple_channel.delete_with_videos()
|
||||
assert common.create_empty_config_files() == ['channels.yaml'], 'Channels config should have been created.'
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue