Feat: Renaming files in file browser should trigger item update if exists and update sidecars as well.
This commit is contained in:
parent
6d9c7d70d9
commit
1c7444e232
5 changed files with 268 additions and 33 deletions
|
|
@ -763,6 +763,67 @@ def get_file_sidecar(file: Path | None = None) -> dict[dict]:
|
||||||
return files
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
def rename_file(old_path: Path, new_name: str) -> tuple[Path, list[tuple[Path, Path]]]:
|
||||||
|
"""
|
||||||
|
Rename a file along with all its sidecar files.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
old_path (Path): The original file path.
|
||||||
|
new_name (str): The new name for the file (not full path, just the name).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[Path, list[tuple[Path, Path]]]: Tuple of (new_path, list of (old_sidecar_path, new_sidecar_path) tuples).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
OSError: If renaming fails.
|
||||||
|
ValueError: If new_name would cause a collision with existing files.
|
||||||
|
|
||||||
|
"""
|
||||||
|
new_path: Path = old_path.parent / new_name
|
||||||
|
|
||||||
|
if new_path.exists():
|
||||||
|
msg: str = f"Destination '{new_name}' already exists"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
sidecar_data: dict[str, dict] = get_file_sidecar(old_path)
|
||||||
|
sidecar_files: list[Path] = []
|
||||||
|
|
||||||
|
for category in sidecar_data.values():
|
||||||
|
sidecar_files.extend([item["file"] for item in category if "file" in item and isinstance(item["file"], Path)])
|
||||||
|
|
||||||
|
renamed_sidecars: list[tuple[Path, Path]] = []
|
||||||
|
new_stem: str = new_path.stem
|
||||||
|
rename_operations: list[tuple[Path, Path]] = []
|
||||||
|
|
||||||
|
for sidecar in sidecar_files:
|
||||||
|
sidecar_suffix: str = sidecar.name[len(old_path.stem) :] # Everything after the original stem
|
||||||
|
new_sidecar_path: Path = old_path.parent / f"{new_stem}{sidecar_suffix}"
|
||||||
|
if new_sidecar_path.exists():
|
||||||
|
msg = f"Sidecar destination '{new_sidecar_path.name}' already exists"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
rename_operations.append((sidecar, new_sidecar_path))
|
||||||
|
|
||||||
|
try:
|
||||||
|
renamed_main: Path = old_path.rename(new_path)
|
||||||
|
for old_sidecar, new_sidecar in rename_operations:
|
||||||
|
try:
|
||||||
|
renamed_sidecar: Path = old_sidecar.rename(new_sidecar)
|
||||||
|
renamed_sidecars.append((old_sidecar, renamed_sidecar))
|
||||||
|
except OSError as e:
|
||||||
|
LOG.error(f"Failed to rename sidecar '{old_sidecar}': {e}")
|
||||||
|
try:
|
||||||
|
renamed_main.rename(old_path)
|
||||||
|
except OSError:
|
||||||
|
LOG.error(f"Failed to rollback main file rename from '{renamed_main}' to '{old_path}'")
|
||||||
|
raise
|
||||||
|
|
||||||
|
return renamed_main, renamed_sidecars
|
||||||
|
except OSError as e:
|
||||||
|
LOG.error(f"Failed to rename '{old_path}' to '{new_path}': {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
def get_possible_images(dir: str) -> list[dict]:
|
def get_possible_images(dir: str) -> list[dict]:
|
||||||
images: list = []
|
images: list = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,10 +9,12 @@ from aiohttp.web import Request, Response
|
||||||
|
|
||||||
from app.library.cache import Cache
|
from app.library.cache import Cache
|
||||||
from app.library.config import Config
|
from app.library.config import Config
|
||||||
|
from app.library.DownloadQueue import DownloadQueue
|
||||||
from app.library.encoder import Encoder
|
from app.library.encoder import Encoder
|
||||||
|
from app.library.Events import EventBus, Events
|
||||||
from app.library.ffprobe import ffprobe
|
from app.library.ffprobe import ffprobe
|
||||||
from app.library.router import route
|
from app.library.router import route
|
||||||
from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type
|
from app.library.Utils import delete_dir, get_file, get_file_sidecar, get_files, get_mime_type, rename_file
|
||||||
|
|
||||||
LOG: logging.Logger = logging.getLogger(__name__)
|
LOG: logging.Logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -175,13 +177,15 @@ async def file_browser(request: Request, config: Config, encoder: Encoder) -> Re
|
||||||
|
|
||||||
|
|
||||||
@route("POST", "api/file/actions", "browser.file.actions")
|
@route("POST", "api/file/actions", "browser.file.actions")
|
||||||
async def path_actions(request: Request, config: Config) -> Response:
|
async def path_actions(request: Request, config: Config, queue: DownloadQueue, notify: EventBus) -> Response:
|
||||||
"""
|
"""
|
||||||
Browser actions.
|
Browser actions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
request (Request): The request object.
|
request (Request): The request object.
|
||||||
config (Config): The configuration object.
|
config (Config): The configuration object.
|
||||||
|
queue (DownloadQueue): The download queue instance.
|
||||||
|
notify (EventBus): The event bus instance.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Response: The response object.
|
Response: The response object.
|
||||||
|
|
@ -360,16 +364,47 @@ async def path_actions(request: Request, config: Config) -> Response:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
renamed = path.rename(new_path)
|
# Rename main file and all sidecar files
|
||||||
|
renamed, renamed_sidecars = rename_file(path, new_name)
|
||||||
|
|
||||||
|
sidecar_count: int = len(renamed_sidecars)
|
||||||
|
sidecar_info: str = (
|
||||||
|
f" (with {sidecar_count} sidecar file{'s' if sidecar_count != 1 else ''})"
|
||||||
|
if sidecar_count > 0
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
|
||||||
LOG.info(
|
LOG.info(
|
||||||
f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'"
|
f"Renamed '{path.relative_to(config.download_path)}' to '{renamed.relative_to(config.download_path)}'{sidecar_info}"
|
||||||
)
|
)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
LOG.exception(e)
|
LOG.exception(e)
|
||||||
record(path, ok=False, error=str(e), action=action, extra={"item": params})
|
record(path, ok=False, error=str(e), action=action, extra={"item": params})
|
||||||
continue
|
continue
|
||||||
|
except ValueError as e:
|
||||||
|
record(path, ok=False, error=str(e), action=action, extra={"item": params})
|
||||||
|
continue
|
||||||
else:
|
else:
|
||||||
record(path, ok=True, action=action, extra={"new_path": renamed})
|
extra_info: dict[str, Path] = {"new_path": renamed}
|
||||||
|
if renamed_sidecars:
|
||||||
|
extra_info["sidecar_count"] = len(renamed_sidecars)
|
||||||
|
record(path, ok=True, action=action, extra=extra_info)
|
||||||
|
|
||||||
|
for old_sidecar, new_sidecar in renamed_sidecars:
|
||||||
|
record(
|
||||||
|
old_sidecar,
|
||||||
|
ok=True,
|
||||||
|
action="rename_sidecar",
|
||||||
|
extra={"new_path": new_sidecar, "parent": str(path.relative_to(config.download_path))},
|
||||||
|
)
|
||||||
|
|
||||||
|
if item := queue.done.get_item(filename=str(path.relative_to(config.download_path))):
|
||||||
|
item.info.filename = str(renamed.relative_to(config.download_path))
|
||||||
|
if len(renamed_sidecars) > 0:
|
||||||
|
item.info.get_file_sidecar()
|
||||||
|
|
||||||
|
queue.done.put(item)
|
||||||
|
notify.emit(Events.ITEM_UPDATED, data=item.info)
|
||||||
|
|
||||||
if "delete" == action:
|
if "delete" == action:
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ from app.library.Utils import (
|
||||||
merge_dict,
|
merge_dict,
|
||||||
parse_tags,
|
parse_tags,
|
||||||
read_logfile,
|
read_logfile,
|
||||||
|
rename_file,
|
||||||
str_to_dt,
|
str_to_dt,
|
||||||
strip_newline,
|
strip_newline,
|
||||||
tail_log,
|
tail_log,
|
||||||
|
|
@ -1731,9 +1732,7 @@ class TestGetChannelImages:
|
||||||
"""Test extracting poster image from portrait ratio thumbnail."""
|
"""Test extracting poster image from portrait ratio thumbnail."""
|
||||||
from app.library.Utils import get_channel_images
|
from app.library.Utils import get_channel_images
|
||||||
|
|
||||||
thumbnails = [
|
thumbnails = [{"url": "http://example.com/poster.jpg", "width": 200, "height": 300, "id": "some_id"}]
|
||||||
{"url": "http://example.com/poster.jpg", "width": 200, "height": 300, "id": "some_id"}
|
|
||||||
]
|
|
||||||
|
|
||||||
result = get_channel_images(thumbnails)
|
result = get_channel_images(thumbnails)
|
||||||
|
|
||||||
|
|
@ -2049,3 +2048,144 @@ class TestCreateCookiesFile:
|
||||||
assert result == cookie_path
|
assert result == cookie_path
|
||||||
assert cookie_path.read_text() == "new_data"
|
assert cookie_path.read_text() == "new_data"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRenameFile:
|
||||||
|
"""Test rename_file function."""
|
||||||
|
|
||||||
|
def test_rename_single_file_no_sidecars(self, tmp_path: Path):
|
||||||
|
"""Test renaming a single file without sidecar files."""
|
||||||
|
# Create test file
|
||||||
|
test_file = tmp_path / "video.mp4"
|
||||||
|
test_file.write_text("test content")
|
||||||
|
|
||||||
|
# Rename file
|
||||||
|
new_path, sidecars = rename_file(test_file, "renamed_video.mp4")
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert new_path.exists()
|
||||||
|
assert "renamed_video.mp4" == new_path.name
|
||||||
|
assert not test_file.exists()
|
||||||
|
assert 0 == len(sidecars)
|
||||||
|
|
||||||
|
def test_rename_file_with_subtitle_sidecar(self, tmp_path: Path):
|
||||||
|
"""Test renaming a file with subtitle sidecar."""
|
||||||
|
# Create test files
|
||||||
|
test_file = tmp_path / "video.mp4"
|
||||||
|
test_file.write_text("test video")
|
||||||
|
|
||||||
|
subtitle_file = tmp_path / "video.en.srt"
|
||||||
|
subtitle_file.write_text("test subtitle")
|
||||||
|
|
||||||
|
# Rename file
|
||||||
|
new_path, sidecars = rename_file(test_file, "renamed_video.mp4")
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert new_path.exists()
|
||||||
|
assert "renamed_video.mp4" == new_path.name
|
||||||
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
assert 1 == len(sidecars)
|
||||||
|
old_sidecar, new_sidecar = sidecars[0]
|
||||||
|
assert new_sidecar.exists()
|
||||||
|
assert "renamed_video.en.srt" == new_sidecar.name
|
||||||
|
assert old_sidecar == subtitle_file
|
||||||
|
assert not subtitle_file.exists()
|
||||||
|
|
||||||
|
def test_rename_file_with_multiple_sidecars(self, tmp_path: Path):
|
||||||
|
"""Test renaming a file with multiple sidecar files."""
|
||||||
|
# Create test files
|
||||||
|
test_file = tmp_path / "video.mp4"
|
||||||
|
test_file.write_text("test video")
|
||||||
|
|
||||||
|
subtitle_en = tmp_path / "video.en.srt"
|
||||||
|
subtitle_en.write_text("english subtitle")
|
||||||
|
|
||||||
|
subtitle_fr = tmp_path / "video.fr.srt"
|
||||||
|
subtitle_fr.write_text("french subtitle")
|
||||||
|
|
||||||
|
info_file = tmp_path / "video.info.json"
|
||||||
|
info_file.write_text('{"title": "test"}')
|
||||||
|
|
||||||
|
# Rename file
|
||||||
|
new_path, sidecars = rename_file(test_file, "renamed_video.mp4")
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert new_path.exists()
|
||||||
|
assert "renamed_video.mp4" == new_path.name
|
||||||
|
assert not test_file.exists()
|
||||||
|
|
||||||
|
assert 3 == len(sidecars)
|
||||||
|
|
||||||
|
# Check all sidecars were renamed
|
||||||
|
sidecar_names = {new_sidecar.name for old_sidecar, new_sidecar in sidecars}
|
||||||
|
assert "renamed_video.en.srt" in sidecar_names
|
||||||
|
assert "renamed_video.fr.srt" in sidecar_names
|
||||||
|
assert "renamed_video.info.json" in sidecar_names
|
||||||
|
|
||||||
|
# Check old files don't exist
|
||||||
|
assert not subtitle_en.exists()
|
||||||
|
assert not subtitle_fr.exists()
|
||||||
|
assert not info_file.exists()
|
||||||
|
|
||||||
|
def test_rename_file_destination_exists(self, tmp_path: Path):
|
||||||
|
"""Test renaming a file when destination already exists."""
|
||||||
|
# Create test files
|
||||||
|
test_file = tmp_path / "video.mp4"
|
||||||
|
test_file.write_text("test content")
|
||||||
|
|
||||||
|
existing_file = tmp_path / "renamed_video.mp4"
|
||||||
|
existing_file.write_text("existing content")
|
||||||
|
|
||||||
|
# Should raise ValueError
|
||||||
|
with pytest.raises(ValueError, match="already exists"):
|
||||||
|
rename_file(test_file, "renamed_video.mp4")
|
||||||
|
|
||||||
|
# Original files should still exist
|
||||||
|
assert test_file.exists()
|
||||||
|
assert existing_file.exists()
|
||||||
|
|
||||||
|
def test_rename_file_sidecar_destination_exists(self, tmp_path: Path):
|
||||||
|
"""Test renaming when sidecar destination already exists."""
|
||||||
|
# Create test files
|
||||||
|
test_file = tmp_path / "video.mp4"
|
||||||
|
test_file.write_text("test video")
|
||||||
|
|
||||||
|
subtitle_file = tmp_path / "video.en.srt"
|
||||||
|
subtitle_file.write_text("test subtitle")
|
||||||
|
|
||||||
|
# Create conflicting sidecar destination
|
||||||
|
conflicting_sidecar = tmp_path / "renamed_video.en.srt"
|
||||||
|
conflicting_sidecar.write_text("existing subtitle")
|
||||||
|
|
||||||
|
# Should raise ValueError
|
||||||
|
with pytest.raises(ValueError, match=r"Sidecar destination.*already exists"):
|
||||||
|
rename_file(test_file, "renamed_video.mp4")
|
||||||
|
|
||||||
|
# Original files should still exist
|
||||||
|
assert test_file.exists()
|
||||||
|
assert subtitle_file.exists()
|
||||||
|
assert conflicting_sidecar.exists()
|
||||||
|
|
||||||
|
def test_rename_preserves_sidecar_extensions(self, tmp_path: Path):
|
||||||
|
"""Test that rename preserves complex sidecar extensions."""
|
||||||
|
# Create test files with complex extensions
|
||||||
|
test_file = tmp_path / "video.mp4"
|
||||||
|
test_file.write_text("test video")
|
||||||
|
|
||||||
|
subtitle_file = tmp_path / "video.en-US.ass"
|
||||||
|
subtitle_file.write_text("test subtitle")
|
||||||
|
|
||||||
|
thumb_file = tmp_path / "video.thumb.jpg"
|
||||||
|
thumb_file.write_text("test thumb")
|
||||||
|
|
||||||
|
# Rename file
|
||||||
|
new_path, sidecars = rename_file(test_file, "renamed.mp4")
|
||||||
|
|
||||||
|
# Assertions
|
||||||
|
assert new_path.exists()
|
||||||
|
assert "renamed.mp4" == new_path.name
|
||||||
|
|
||||||
|
assert 2 == len(sidecars)
|
||||||
|
sidecar_names = {new_sidecar.name for old_sidecar, new_sidecar in sidecars}
|
||||||
|
assert "renamed.en-US.ass" in sidecar_names
|
||||||
|
assert "renamed.thumb.jpg" in sidecar_names
|
||||||
|
|
|
||||||
|
|
@ -51,13 +51,6 @@ const sendMessage = (type: notificationType, id: string, message: string, opts?:
|
||||||
const useToastNotification = !window.isSecureContext || 'toast' === toastTarget.value ||
|
const useToastNotification = !window.isSecureContext || 'toast' === toastTarget.value ||
|
||||||
!('Notification' in window) || 'granted' !== Notification.permission;
|
!('Notification' in window) || 'granted' !== Notification.permission;
|
||||||
|
|
||||||
console.log('useToastNotification', useToastNotification,{
|
|
||||||
windowIsSecureContext: window.isSecureContext,
|
|
||||||
notificationTarget: toastTarget.value,
|
|
||||||
notificationInWindow: 'Notification' in window,
|
|
||||||
notificationPermission: Notification.permission
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useToastNotification) {
|
if (useToastNotification) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'info':
|
case 'info':
|
||||||
|
|
|
||||||
|
|
@ -728,7 +728,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
if ('rename' === action) {
|
if ('rename' === action) {
|
||||||
const { status, value: newName } = await dialog.promptDialog({
|
const { status, value: newName } = await dialog.promptDialog({
|
||||||
title: 'Rename Item',
|
title: 'Rename Item',
|
||||||
message: `Enter new name for '${item.name}':`,
|
message: `Enter new name for '${item.name}' Sidecars will be renamed as well:`,
|
||||||
initial: item.name,
|
initial: item.name,
|
||||||
confirmText: 'Rename',
|
confirmText: 'Rename',
|
||||||
cancelText: 'Cancel',
|
cancelText: 'Cancel',
|
||||||
|
|
@ -742,11 +742,13 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await actionRequest(item, 'rename', { new_name: new_name }, (item, _, data) => {
|
await actionRequest(item, 'rename', { new_name: new_name }, (item, _, data, source) => {
|
||||||
item.name = data.new_name
|
if (item.path === source.path) {
|
||||||
item.path = item.path.replace(/[^/]+$/, data.new_name)
|
toast.success(`Renamed '${item.name}'.`)
|
||||||
toast.success(`Renamed '${item.name}'.`)
|
}
|
||||||
})
|
item.name = basename(data.new_path)
|
||||||
|
item.path = data.new_path
|
||||||
|
}, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -790,7 +792,7 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await actionRequest(item, 'move', { new_path: new_path }, (item, action, data) => {
|
await actionRequest(item, 'move', { new_path: new_path }, (item, _, data) => {
|
||||||
items.value = items.value.filter(i => i.path !== item.path)
|
items.value = items.value.filter(i => i.path !== item.path)
|
||||||
toast.success(`Moved '${item.name}' to '${data.new_path}'.`)
|
toast.success(`Moved '${item.name}' to '${data.new_path}'.`)
|
||||||
})
|
})
|
||||||
|
|
@ -802,25 +804,22 @@ const handleAction = async (action: string, item: FileItem): Promise<void> => {
|
||||||
const actionRequest = async (
|
const actionRequest = async (
|
||||||
item: FileItem,
|
item: FileItem,
|
||||||
action: string,
|
action: string,
|
||||||
data: Record<string, any>,
|
req: Record<string, any>,
|
||||||
cb: (item: FileItem, action: string, data: any) => void
|
cb: (item: FileItem, action: string, data: any, source: FileItem, req: any) => void,
|
||||||
|
multiple: boolean = false
|
||||||
): Promise<any> => {
|
): Promise<any> => {
|
||||||
if (!config.app.browser_control_enabled) {
|
if (!config.app.browser_control_enabled) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!item || !action || !data) {
|
if (!item || !action || !req) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await request(`/api/file/actions`, {
|
const response = await request(`/api/file/actions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify([{
|
body: JSON.stringify([{ path: item.path, action: action, ...req }]),
|
||||||
path: item.path,
|
|
||||||
action: action,
|
|
||||||
...data
|
|
||||||
}]),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
|
@ -831,17 +830,24 @@ const actionRequest = async (
|
||||||
|
|
||||||
const json = await response.json() as Array<{ path: string, status: boolean, error?: string }>
|
const json = await response.json() as Array<{ path: string, status: boolean, error?: string }>
|
||||||
json.forEach(i => {
|
json.forEach(i => {
|
||||||
if (i.path !== item.path) {
|
if (false === multiple && i.path !== item.path) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (true !== i.status) {
|
if (false === multiple && true !== i.status) {
|
||||||
toast.error(`Failed to perform action: ${i.error || 'Unknown error'}`)
|
toast.error(`Failed to perform action: ${i.error || 'Unknown error'}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cb && typeof cb === 'function') {
|
if (cb && typeof cb === 'function') {
|
||||||
cb(item, action, data)
|
if (false === multiple) {
|
||||||
|
return cb(item, action, req, item, req)
|
||||||
|
}
|
||||||
|
items.value.forEach(it => {
|
||||||
|
if (it.path === i.path) {
|
||||||
|
return cb(it, action, i, toRaw(item), req)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue