Major update with lots of new features and fixes

This commit is contained in:
Luna 2026-05-17 05:36:04 -07:00
parent 17c149c21a
commit 74b3efd990
16 changed files with 1648 additions and 904 deletions

5
.gitignore vendored
View file

@ -4,6 +4,11 @@
# Rust build artifacts # Rust build artifacts
/target/ /target/
# makepkg build artifacts (finished .pkg.tar.zst is NOT ignored)
/pkg/
/src/yt-offline/
/yt-offline/
# Editor / OS cruft # Editor / OS cruft
*.swp *.swp
.DS_Store .DS_Store

31
.vscode/launch.json vendored Normal file
View file

@ -0,0 +1,31 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": "Debug executable 'yt-offline'",
"type": "lldb",
"request": "launch",
"cargo": {
"args": [
"run",
"--bin=yt-offline"
]
},
"args": []
},
{
"name": "Debug unit tests in executable 'yt-offline'",
"type": "lldb",
"request": "launch",
"cargo": {
"args": [
"test",
"--bin=yt-offline"
]
}
}
]
}

677
Cargo.lock generated
View file

@ -208,7 +208,7 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [ dependencies = [
"libloading 0.8.9", "libloading",
] ]
[[package]] [[package]]
@ -353,29 +353,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "atk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
dependencies = [
"atk-sys",
"glib",
"libc",
]
[[package]]
name = "atk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@ -532,9 +509,6 @@ name = "bitflags"
version = "2.11.1" version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
dependencies = [
"serde_core",
]
[[package]] [[package]]
name = "block" name = "block"
@ -620,31 +594,6 @@ version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cairo-rs"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
dependencies = [
"bitflags 2.11.1",
"cairo-sys-rs",
"glib",
"libc",
"once_cell",
"thiserror 1.0.69",
]
[[package]]
name = "cairo-sys-rs"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]] [[package]]
name = "calloop" name = "calloop"
version = "0.13.0" version = "0.13.0"
@ -708,16 +657,6 @@ dependencies = [
"shlex", "shlex",
] ]
[[package]]
name = "cfg-expr"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
dependencies = [
"smallvec",
"target-lexicon",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@ -754,36 +693,6 @@ dependencies = [
"error-code", "error-code",
] ]
[[package]]
name = "cocoa"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c"
dependencies = [
"bitflags 1.3.2",
"block",
"cocoa-foundation",
"core-foundation 0.9.4",
"core-graphics",
"foreign-types",
"libc",
"objc",
]
[[package]]
name = "cocoa-foundation"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7"
dependencies = [
"bitflags 1.3.2",
"block",
"core-foundation 0.9.4",
"core-graphics-types",
"libc",
"objc",
]
[[package]] [[package]]
name = "codespan-reporting" name = "codespan-reporting"
version = "0.11.1" version = "0.11.1"
@ -912,15 +821,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@ -962,27 +862,6 @@ dependencies = [
"crypto-common", "crypto-common",
] ]
[[package]]
name = "dirs-next"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1"
dependencies = [
"cfg-if",
"dirs-sys-next",
]
[[package]]
name = "dirs-sys-next"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d"
dependencies = [
"libc",
"redox_users",
"winapi",
]
[[package]] [[package]]
name = "dispatch" name = "dispatch"
version = "0.2.0" version = "0.2.0"
@ -1016,7 +895,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [ dependencies = [
"libloading 0.8.9", "libloading",
] ]
[[package]] [[package]]
@ -1282,16 +1161,6 @@ dependencies = [
"simd-adler32", "simd-adler32",
] ]
[[package]]
name = "field-offset"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
dependencies = [
"memoffset",
"rustc_version",
]
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -1365,17 +1234,6 @@ version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
[[package]]
name = "futures-executor"
version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
dependencies = [
"futures-core",
"futures-task",
"futures-util",
]
[[package]] [[package]]
name = "futures-io" name = "futures-io"
version = "0.3.32" version = "0.3.32"
@ -1434,64 +1292,6 @@ dependencies = [
"slab", "slab",
] ]
[[package]]
name = "gdk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
dependencies = [
"cairo-rs",
"gdk-pixbuf",
"gdk-sys",
"gio",
"glib",
"libc",
"pango",
]
[[package]]
name = "gdk-pixbuf"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
dependencies = [
"gdk-pixbuf-sys",
"gio",
"glib",
"libc",
"once_cell",
]
[[package]]
name = "gdk-pixbuf-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
dependencies = [
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]]
name = "gdk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
dependencies = [
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"pkg-config",
"system-deps",
]
[[package]] [[package]]
name = "generic-array" name = "generic-array"
version = "0.14.7" version = "0.14.7"
@ -1548,38 +1348,6 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "gio"
version = "0.18.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
dependencies = [
"futures-channel",
"futures-core",
"futures-io",
"futures-util",
"gio-sys",
"glib",
"libc",
"once_cell",
"pin-project-lite",
"smallvec",
"thiserror 1.0.69",
]
[[package]]
name = "gio-sys"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
"winapi",
]
[[package]] [[package]]
name = "gl_generator" name = "gl_generator"
version = "0.14.0" version = "0.14.0"
@ -1591,53 +1359,6 @@ dependencies = [
"xml-rs", "xml-rs",
] ]
[[package]]
name = "glib"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
dependencies = [
"bitflags 2.11.1",
"futures-channel",
"futures-core",
"futures-executor",
"futures-task",
"futures-util",
"gio-sys",
"glib-macros",
"glib-sys",
"gobject-sys",
"libc",
"memchr",
"once_cell",
"smallvec",
"thiserror 1.0.69",
]
[[package]]
name = "glib-macros"
version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
dependencies = [
"heck 0.4.1",
"proc-macro-crate 2.0.2",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "glib-sys"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
dependencies = [
"libc",
"system-deps",
]
[[package]] [[package]]
name = "glow" name = "glow"
version = "0.13.1" version = "0.13.1"
@ -1675,7 +1396,7 @@ dependencies = [
"glutin_egl_sys", "glutin_egl_sys",
"glutin_glx_sys", "glutin_glx_sys",
"glutin_wgl_sys", "glutin_wgl_sys",
"libloading 0.8.9", "libloading",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-app-kit 0.3.2", "objc2-app-kit 0.3.2",
"objc2-core-foundation", "objc2-core-foundation",
@ -1728,17 +1449,6 @@ dependencies = [
"gl_generator", "gl_generator",
] ]
[[package]]
name = "gobject-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
dependencies = [
"glib-sys",
"libc",
"system-deps",
]
[[package]] [[package]]
name = "gpu-alloc" name = "gpu-alloc"
version = "0.6.0" version = "0.6.0"
@ -1791,58 +1501,6 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
] ]
[[package]]
name = "gtk"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
dependencies = [
"atk",
"cairo-rs",
"field-offset",
"futures-channel",
"gdk",
"gdk-pixbuf",
"gio",
"glib",
"gtk-sys",
"gtk3-macros",
"libc",
"pango",
"pkg-config",
]
[[package]]
name = "gtk-sys"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
dependencies = [
"atk-sys",
"cairo-sys-rs",
"gdk-pixbuf-sys",
"gdk-sys",
"gio-sys",
"glib-sys",
"gobject-sys",
"libc",
"pango-sys",
"system-deps",
]
[[package]]
name = "gtk3-macros"
version = "0.18.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.14.5" version = "0.14.5"
@ -1885,18 +1543,12 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"com", "com",
"libc", "libc",
"libloading 0.8.9", "libloading",
"thiserror 1.0.69", "thiserror 1.0.69",
"widestring", "widestring",
"winapi", "winapi",
] ]
[[package]]
name = "heck"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@ -1954,6 +1606,12 @@ dependencies = [
"pin-project-lite", "pin-project-lite",
] ]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]] [[package]]
name = "httparse" name = "httparse"
version = "1.10.1" version = "1.10.1"
@ -2121,7 +1779,7 @@ dependencies = [
"image-webp", "image-webp",
"moxcms", "moxcms",
"num-traits", "num-traits",
"png 0.18.1", "png",
"zune-core", "zune-core",
"zune-jpeg", "zune-jpeg",
] ]
@ -2243,17 +1901,6 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "keyboard-types"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
dependencies = [
"bitflags 2.11.1",
"serde",
"unicode-segmentation",
]
[[package]] [[package]]
name = "khronos-egl" name = "khronos-egl"
version = "6.0.0" version = "6.0.0"
@ -2261,7 +1908,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [ dependencies = [
"libc", "libc",
"libloading 0.8.9", "libloading",
"pkg-config", "pkg-config",
] ]
@ -2277,46 +1924,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libappindicator"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
dependencies = [
"glib",
"gtk",
"gtk-sys",
"libappindicator-sys",
"log",
]
[[package]]
name = "libappindicator-sys"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
"libloading 0.7.4",
"once_cell",
]
[[package]] [[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]]
name = "libloading"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
dependencies = [
"cfg-if",
"winapi",
]
[[package]] [[package]]
name = "libloading" name = "libloading"
version = "0.8.9" version = "0.8.9"
@ -2350,25 +1963,6 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "libxdo"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
dependencies = [
"libxdo-sys",
]
[[package]]
name = "libxdo-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
dependencies = [
"libc",
"x11",
]
[[package]] [[package]]
name = "linux-raw-sys" name = "linux-raw-sys"
version = "0.4.15" version = "0.4.15"
@ -2480,6 +2074,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mime_guess"
version = "2.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
dependencies = [
"mime",
"unicase",
]
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.8.9" version = "0.8.9"
@ -2511,25 +2115,6 @@ dependencies = [
"pxfm", "pxfm",
] ]
[[package]]
name = "muda"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "86b959f97c97044e4c96e32e1db292a7d594449546a3c6b77ae613dc3a5b5145"
dependencies = [
"cocoa",
"crossbeam-channel",
"dpi",
"gtk",
"keyboard-types",
"libxdo",
"objc",
"once_cell",
"png 0.17.16",
"thiserror 1.0.69",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "naga" name = "naga"
version = "22.1.0" version = "22.1.0"
@ -2654,7 +2239,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
@ -2977,31 +2562,6 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "pango"
version = "0.18.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
dependencies = [
"gio",
"glib",
"libc",
"once_cell",
"pango-sys",
]
[[package]]
name = "pango-sys"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
dependencies = [
"glib-sys",
"gobject-sys",
"libc",
"system-deps",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -3092,19 +2652,6 @@ version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]] [[package]]
name = "png" name = "png"
version = "0.18.1" version = "0.18.1"
@ -3172,26 +2719,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "proc-macro-crate"
version = "1.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
dependencies = [
"once_cell",
"toml_edit 0.19.15",
]
[[package]]
name = "proc-macro-crate"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24"
dependencies = [
"toml_datetime 0.6.3",
"toml_edit 0.20.2",
]
[[package]] [[package]]
name = "proc-macro-crate" name = "proc-macro-crate"
version = "3.5.0" version = "3.5.0"
@ -3201,30 +2728,6 @@ dependencies = [
"toml_edit 0.25.11+spec-1.1.0", "toml_edit 0.25.11+spec-1.1.0",
] ]
[[package]]
name = "proc-macro-error"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [
"proc-macro-error-attr",
"proc-macro2",
"quote",
"syn 1.0.109",
"version_check",
]
[[package]]
name = "proc-macro-error-attr"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [
"proc-macro2",
"quote",
"version_check",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -3364,17 +2867,6 @@ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
] ]
[[package]]
name = "redox_users"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
dependencies = [
"getrandom 0.2.17",
"libredox",
"thiserror 1.0.69",
]
[[package]] [[package]]
name = "renderdoc-sys" name = "renderdoc-sys"
version = "1.1.0" version = "1.1.0"
@ -3798,25 +3290,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "system-deps"
version = "6.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml",
"version-compare",
]
[[package]]
name = "target-lexicon"
version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
[[package]] [[package]]
name = "tauri-winrt-notification" name = "tauri-winrt-notification"
version = "0.7.2" version = "0.7.2"
@ -3973,6 +3446,30 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
dependencies = [
"bytes",
"futures-core",
"futures-sink",
"pin-project-lite",
"tokio",
]
[[package]] [[package]]
name = "toml" name = "toml"
version = "0.8.2" version = "0.8.2"
@ -4003,17 +3500,6 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "toml_edit"
version = "0.19.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
dependencies = [
"indexmap",
"toml_datetime 0.6.3",
"winnow 0.5.40",
]
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.20.2" version = "0.20.2"
@ -4072,12 +3558,21 @@ checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"bytes", "bytes",
"futures-util",
"http", "http",
"http-body", "http-body",
"http-body-util", "http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite", "pin-project-lite",
"tokio",
"tokio-util",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
@ -4124,25 +3619,6 @@ dependencies = [
"once_cell", "once_cell",
] ]
[[package]]
name = "tray-icon"
version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39240037d755a1832e752d64f99078c3b0b21c09a71c12405070c75ef4e7cd3c"
dependencies = [
"cocoa",
"core-graphics",
"crossbeam-channel",
"dirs-next",
"libappindicator",
"muda",
"objc",
"once_cell",
"png 0.17.16",
"thiserror 1.0.69",
"windows-sys 0.52.0",
]
[[package]] [[package]]
name = "ttf-parser" name = "ttf-parser"
version = "0.25.1" version = "0.25.1"
@ -4175,6 +3651,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "unicase"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
[[package]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.24" version = "1.0.24"
@ -4234,12 +3716,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
[[package]] [[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
@ -4610,7 +4086,7 @@ dependencies = [
"js-sys", "js-sys",
"khronos-egl", "khronos-egl",
"libc", "libc",
"libloading 0.8.9", "libloading",
"log", "log",
"metal", "metal",
"naga", "naga",
@ -5144,7 +4620,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck 0.5.0", "heck",
"wit-parser", "wit-parser",
] ]
@ -5155,7 +4631,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"heck 0.5.0", "heck",
"indexmap", "indexmap",
"prettyplease", "prettyplease",
"syn 2.0.117", "syn 2.0.117",
@ -5222,16 +4698,6 @@ version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
[[package]]
name = "x11"
version = "2.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e"
dependencies = [
"libc",
"pkg-config",
]
[[package]] [[package]]
name = "x11-dl" name = "x11-dl"
version = "2.21.0" version = "2.21.0"
@ -5252,7 +4718,7 @@ dependencies = [
"as-raw-xcb-connection", "as-raw-xcb-connection",
"gethostname", "gethostname",
"libc", "libc",
"libloading 0.8.9", "libloading",
"once_cell", "once_cell",
"rustix 1.1.4", "rustix 1.1.4",
"x11rb-protocol", "x11rb-protocol",
@ -5340,9 +4806,10 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
"tokio-stream",
"tokio-util",
"toml", "toml",
"tower-http", "tower-http",
"tray-icon",
] ]
[[package]] [[package]]
@ -5448,7 +4915,7 @@ version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e" checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
@ -5461,7 +4928,7 @@ version = "5.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff" checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
@ -5633,7 +5100,7 @@ version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449" checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",
@ -5646,7 +5113,7 @@ version = "5.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda" checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
dependencies = [ dependencies = [
"proc-macro-crate 3.5.0", "proc-macro-crate",
"proc-macro2", "proc-macro2",
"quote", "quote",
"syn 2.0.117", "syn 2.0.117",

View file

@ -11,11 +11,12 @@ toml = "0.8"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0" serde_json = "1.0"
rusqlite = { version = "0.31", features = ["bundled"] } rusqlite = { version = "0.31", features = ["bundled"] }
tray-icon = "0.13"
notify-rust = { version = "4", default-features = false, features = ["z"] } notify-rust = { version = "4", default-features = false, features = ["z"] }
axum = { version = "0.7", features = ["macros"] } axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
tower-http = { version = "0.5", features = ["cors"] } tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = "0.1"
tower-http = { version = "0.5", features = ["cors", "fs"] }
[profile.release] [profile.release]
opt-level = 2 opt-level = 2

212
SECURITY_AUDIT.md Normal file
View file

@ -0,0 +1,212 @@
# Security Audit — yt-offline
**Date:** 2026-05-17
**Scope:** Rust codebase + dependencies
**Threat Model:** Self-hosted personal YouTube archiving tool; single user accessing via localhost + LAN
---
## Summary
✅ **Overall Risk: LOW**
No critical vulnerabilities found. The codebase follows security best practices for command execution, database access, and file serving. One informational warning on a transitive dependency (low risk).
---
## Findings
### ✅ Command Injection — SECURE
All external process invocations use `std::process::Command` or `tokio::process::Command` with `.arg()` calls, bypassing shell interpretation:
- **yt-dlp invocations** (`src/downloader.rs:160188`): User URL passed as a clean argument, not shell-interpreted. Safe even with special characters.
- **ffmpeg invocations** (`src/web.rs:404415`): Similarly safe, using separate `.arg()` calls.
- **Preview handler** (`src/web.rs:492498`): yt-dlp preview command also uses `.arg()`, not vulnerable to command injection.
**No shell escaping needed; no `format!()` in command strings.**
---
### ✅ SQL Injection — SECURE
All database queries in `src/database.rs` use **parameterized statements**:
```rust
// Correct — parameter substitution
self.conn.execute(
"INSERT OR REPLACE INTO watched (video_id) VALUES (?1)",
[video_id],
)?;
// Correct — rusqlite::params! macro
self.conn.execute(
"INSERT OR REPLACE INTO positions (video_id, position_secs) VALUES (?1, ?2)",
rusqlite::params![video_id, position_secs],
)?;
```
**No string concatenation in SQL strings.** Video IDs come from path parameters and are bound safely.
---
### ✅ Path Traversal — SECURE
**File serving** (`src/web.rs:687`):
- Uses `tower_http::services::ServeDir::new(&channels_root)` — a well-audited crate that prevents `..` traversal.
**Info.json access** (`src/web.rs:230236`):
- Videos are looked up **only** from the scanned library (`find_video_info_path`).
- Cannot directly request arbitrary files by path; must exist in the library.
**URL path encoding** (`src/web.rs:176186`):
- Correct percent-encoding of URL segments.
- Path components are filtered to `Component::Normal` (rejects `..` and symlinks).
---
### ✅ Unsafe Code — NONE FOUND
**Result of search:** `grep -r "unsafe"` across all `.rs` files returns **zero unsafe blocks**.
All memory safety is guaranteed by Rust's type system. No C FFI or raw pointers.
---
### ⚠️ Dependency Warning (LOW RISK)
**Crate:** `paste` v1.0.15
**Status:** Unmaintained since 2024-10-07
**Severity:** Warning (not an error)
**Dependency Chain:**
```
paste 1.0.15
← egui-wgpu (GUI rendering)
← eframe (GUI framework)
← yt-offline (your app)
```
**Impact:**
- `paste` is a procedural macro for code generation; it does not process untrusted input.
- The crate is used only at compile-time for the GUI stack.
- No security-critical functionality in `paste`.
- **Mitigation:** Monitor for a replacement in the egui/eframe ecosystem. No action needed now.
---
## Input Validation
### Download URLs
- **Handler:** `post_download()` (`src/web.rs:339350`)
- **Validation:** Trimmed and checked for empty; passed directly to yt-dlp via `.arg()`.
- **Risk:** Low — yt-dlp validates URLs server-side and fails gracefully on malformed input.
### Video IDs
- **Source:** Extracted from URL paths and library scans.
- **Usage:** Passed to SQL via parameterized queries and used to look up files in the library.
- **Risk:** Low — parameterized queries eliminate injection; library lookup prevents arbitrary file access.
### Position values
- **Handler:** `post_resume()` (`src/web.rs:464479`)
- **Validation:** Numeric (f64) — deserialized by serde, bounds-checked (position > 3.0 filter).
- **Risk:** Low — stored in SQLite as REAL, no string manipulation.
### Preview query (yt-dlp URL)
- **Handler:** `get_preview()` (`src/web.rs:484527`)
- **Validation:** Trimmed and checked for empty.
- **Risk:** Low — passed to yt-dlp via `.arg()`, no shell injection.
---
## Data Exposure
### Watched status & resume positions
- **Storage:** SQLite database (`yt-offline.db`), local filesystem.
- **Access:** Only available to the user running the app (same UID).
- **No network exposure:** Positions are stored server-side; browser caches position locally.
- **Risk:** Low — local-only; no credentials or PII stored.
### Download logs
- **Stored in:** Job objects, kept in RAM and shown in the UI.
- **Leakage risk:** stderr from yt-dlp is visible in the web UI; sensitive auth errors could be logged.
- **Mitigation:** Consider filtering `--cookies` error messages in production. Currently acceptable for personal use.
### yt-dlp output (preview, metadata)
- **Handler:** Parses JSON from yt-dlp; returns filtered fields to the browser.
- **Risk:** Low — only public YouTube metadata (title, duration, view count).
---
## Configuration & Secrets
### `cookies.txt`
- **Location:** Current working directory (hardcoded in `downloader.rs`).
- **Risk:** File is world-readable by default. **Recommendation:** Ensure restrictive file permissions (`chmod 600 cookies.txt`).
### `config.toml`
- **Contains:** Directory paths, port number, browser name, `source_url`.
- **Risk:** Low — no secrets stored. `source_url` is read-only from browser.
- **Sensitive data:** Only the `backup.directory` path could leak file structure; this is expected behavior.
---
## Network Security
### Web server scope
- **Binding:** Configured via `web.port` in `config.toml` (default 8080).
- **Current binding:** Likely `127.0.0.1:8080` (assumed; verify with netstat).
- **Risk:** If bound to `0.0.0.0`, anyone on the network can access. **Recommendation:** Bind to `127.0.0.1` or use a reverse proxy with authentication.
### No HTTPS
- **Status:** Web UI is served over HTTP.
- **Risk:** Medium if exposed to untrusted networks. **Recommendation:** Use a reverse proxy (nginx, Caddy) with TLS for remote access.
### No authentication
- **Status:** No login required; access is "security by obscurity" (port number).
- **Risk:** Medium if the port is discovered. **Recommendation:** Add optional HTTP Basic Auth or reverse proxy authentication for LAN access.
---
## Recommendations (Priority Order)
### HIGH
1. **Verify web server binding** — Ensure `axum` binds to `127.0.0.1` only, not `0.0.0.0`.
- Check: `src/web.rs:618` where the listener is created.
- Mitigation: Add a config option for `bind_addr` or document localhost-only setup.
### MEDIUM
2. **File permissions on `cookies.txt`** — Remind users to `chmod 600 cookies.txt`.
- Add note to README.
3. **TLS + authentication for remote access** — If exposing to LAN/internet:
- Use a reverse proxy (nginx, Caddy) with mTLS or Basic Auth.
- Or add built-in HTTP Basic Auth via an optional config field.
4. **Filter sensitive logs** — Consider not echoing yt-dlp stderr if it contains auth errors.
- Current: Acceptable for personal use. Not a blocker.
### LOW
5. **Update `paste` crate dependency chain** — Monitor egui/eframe for a replacement.
- Not urgent; no security impact in your use case.
6. **Add Content-Security-Policy headers** — Prevent XSS if web UI is exposed to untrusted networks.
- Current: Low risk (browser only, no JavaScript vulnerabilities found).
---
## Conclusion
The codebase demonstrates **strong security practices** for a personal self-hosted tool:
- ✅ No injection vulnerabilities (command, SQL).
- ✅ No unsafe code.
- ✅ Path traversal protection in place.
- ✅ Parameterized database queries.
- ✅ Safe process spawning.
**No blockers for personal use.** For public/LAN exposure, implement the HIGH-priority recommendations.
---
**Auditor:** Claude Code
**Audit Method:** Manual code review + cargo audit
**Status:** Complete

View file

@ -1,8 +1,18 @@
[backup] [backup]
# Directory where channel backups are stored
directory = "/mnt/InannaBeloved/youtube-backup/channels" directory = "/mnt/InannaBeloved/youtube-backup/channels"
[player] [player]
# Video player command (default: mpv)
# Examples: "mpv", "vlc", "ffplay"
command = "mpv" command = "mpv"
browser = "firefox"
[ui]
theme = "dark"
[scheduler]
enabled = false
interval_hours = 24
[web]
port = 8081
transcode = false
# source_url = "https://codeberg.org/anassaeneroi/yt-offline" # required for AGPL §13

View file

@ -1,7 +1,13 @@
//! egui desktop application — the main window, sidebar, video grid, and settings UI.
//!
//! The [`App`] struct holds all UI state and implements [`eframe::App`].
//! Background work (downloads, scheduled rescans) runs in threads and
//! communicates back via `mpsc` channels stored on `App`.
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use std::sync::mpsc::Receiver; use std::sync::mpsc::{Receiver, Sender};
use std::time::Instant; use std::time::Instant;
use eframe::egui; use eframe::egui;
@ -65,7 +71,9 @@ pub struct App {
show_settings: bool, show_settings: bool,
dl_url: String, dl_url: String,
textures: HashMap<PathBuf, Option<egui::TextureHandle>>, textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
decode_budget: u32, thumb_request_tx: Sender<PathBuf>,
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
thumb_pending: HashSet<PathBuf>,
desc_cache: HashMap<PathBuf, String>, desc_cache: HashMap<PathBuf, String>,
status: String, status: String,
settings_dir: String, settings_dir: String,
@ -82,6 +90,13 @@ pub struct App {
bulk_selected: HashSet<String>, bulk_selected: HashSet<String>,
// Scheduler // Scheduler
last_scheduled_check: Option<Instant>, last_scheduled_check: Option<Instant>,
// Cards cache — recomputed only when inputs change
cards_cache: Vec<Card>,
cards_cache_key: Option<(String, SortMode, SidebarView, u64)>,
library_generation: u64,
// Web server
web_server_running: bool,
web_server_shutdown: Option<Sender<()>>,
} }
impl App { impl App {
@ -117,6 +132,20 @@ impl App {
let browser = config.player.browser.clone(); let browser = config.player.browser.clone();
let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
let (thumb_result_tx, thumb_result_rx) =
std::sync::mpsc::channel::<(PathBuf, Option<egui::ColorImage>)>();
let ctx = cc.egui_ctx.clone();
std::thread::spawn(move || {
while let Ok(path) = thumb_request_rx.recv() {
let img = decode_thumbnail_image(&path);
if thumb_result_tx.send((path, img)).is_err() {
break;
}
ctx.request_repaint();
}
});
Self { Self {
config, config,
config_path, config_path,
@ -130,7 +159,9 @@ impl App {
show_settings: false, show_settings: false,
dl_url: String::new(), dl_url: String::new(),
textures: HashMap::new(), textures: HashMap::new(),
decode_budget: 0, thumb_request_tx,
thumb_result_rx,
thumb_pending: HashSet::new(),
desc_cache: HashMap::new(), desc_cache: HashMap::new(),
status, status,
settings_dir, settings_dir,
@ -145,6 +176,11 @@ impl App {
bulk_mode: false, bulk_mode: false,
bulk_selected: HashSet::new(), bulk_selected: HashSet::new(),
last_scheduled_check: None, last_scheduled_check: None,
cards_cache: Vec::new(),
cards_cache_key: None,
library_generation: 0,
web_server_running: false,
web_server_shutdown: None,
} }
} }
@ -161,7 +197,21 @@ impl App {
); );
} }
fn cards(&self) -> Vec<Card> { fn cards_take(&mut self) -> Vec<Card> {
let key = (
self.search.clone(),
self.sort_mode.clone(),
self.sidebar_view.clone(),
self.library_generation,
);
if self.cards_cache_key.as_ref() != Some(&key) {
self.cards_cache = self.compute_cards();
self.cards_cache_key = Some(key);
}
std::mem::take(&mut self.cards_cache)
}
fn compute_cards(&self) -> Vec<Card> {
let query = self.search.trim().to_lowercase(); let query = self.search.trim().to_lowercase();
let mut cards = Vec::new(); let mut cards = Vec::new();
@ -265,18 +315,15 @@ impl App {
None None
} }
fn texture(&mut self, ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> { fn texture(&mut self, _ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> {
if let Some(slot) = self.textures.get(path) { if let Some(slot) = self.textures.get(path) {
return slot.clone(); return slot.clone();
} }
if self.decode_budget == 0 { let pb = path.to_path_buf();
ctx.request_repaint(); if self.thumb_pending.insert(pb.clone()) {
return None; let _ = self.thumb_request_tx.send(pb);
} }
self.decode_budget -= 1; None
let handle = decode_thumbnail(ctx, path);
self.textures.insert(path.to_path_buf(), handle.clone());
handle
} }
fn description(&mut self, video: &Video) -> String { fn description(&mut self, video: &Video) -> String {
@ -365,6 +412,28 @@ impl App {
} }
} }
fn start_web_server(&mut self) {
if self.web_server_running {
return;
}
let shutdown = crate::web::run_with_shutdown(self.config.clone());
self.web_server_shutdown = Some(shutdown);
self.web_server_running = true;
let port = self.config.web.port;
self.status = format!("Web server started on port {port}. Access at http://localhost:{port}");
}
fn stop_web_server(&mut self) {
if !self.web_server_running {
return;
}
if let Some(shutdown) = self.web_server_shutdown.take() {
let _ = shutdown.send(());
}
self.web_server_running = false;
self.status = "Web server stopped.".to_string();
}
fn bulk_mark_watched(&mut self, watched: bool) { fn bulk_mark_watched(&mut self, watched: bool) {
let ids: Vec<String> = self.bulk_selected.iter().cloned().collect(); let ids: Vec<String> = self.bulk_selected.iter().cloned().collect();
for id in &ids { for id in &ids {
@ -429,10 +498,7 @@ impl App {
} }
fn channel_total_size(ch: &library::Channel) -> u64 { fn channel_total_size(ch: &library::Channel) -> u64 {
ch.videos.iter() ch.total_size_cached
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
.filter_map(|v| v.file_size)
.sum()
} }
fn top_bar(&mut self, ctx: &egui::Context) { fn top_bar(&mut self, ctx: &egui::Context) {
@ -521,14 +587,24 @@ impl App {
let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name) let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name)
for i in 0..self.library.len() { for i in 0..self.library.len() {
let (name, total, has_playlists, size_bytes, channel_url) = { let (name, total, has_playlists, size_bytes, channel_url, url_inferred) = {
let ch = &self.library[i]; let ch = &self.library[i];
let meta_url = ch.meta.as_ref().and_then(|m| m.channel_url.clone());
let (url, inferred) = if let Some(u) = meta_url {
(Some(u), false)
} else {
let folder_url = ch.path.file_name()
.and_then(|n| n.to_str())
.map(|n| format!("https://www.youtube.com/@{n}"));
(folder_url, true)
};
( (
ch.name.clone(), ch.name.clone(),
ch.total_videos(), ch.total_videos(),
!ch.playlists.is_empty(), !ch.playlists.is_empty(),
Self::channel_total_size(ch), Self::channel_total_size(ch),
ch.meta.as_ref().and_then(|m| m.channel_url.clone()), url,
inferred,
) )
}; };
@ -555,13 +631,14 @@ impl App {
let name_for_menu = name.clone(); let name_for_menu = name.clone();
resp.context_menu(|ui| { resp.context_menu(|ui| {
if let Some(ref url) = url_for_menu { if let Some(ref url) = url_for_menu {
if ui.button("⬇ Check for new videos").clicked() { let mut btn = ui.button("⬇ Check for new videos");
if url_inferred {
btn = btn.on_hover_text(format!("URL inferred from folder name:\n{url}"));
}
if btn.clicked() {
pending_ch_download = Some((url.clone(), name_for_menu.clone())); pending_ch_download = Some((url.clone(), name_for_menu.clone()));
ui.close_menu(); ui.close_menu();
} }
} else {
ui.add_enabled(false, egui::Button::new("⬇ Check for new videos"))
.on_hover_text("Download this channel first to store its URL");
} }
if ui.button("📁 Open folder").clicked() { if ui.button("📁 Open folder").clicked() {
let path = self.library[i].path.clone(); let path = self.library[i].path.clone();
@ -786,6 +863,22 @@ impl App {
.range(1024..=65535), .range(1024..=65535),
); );
ui.end_row(); ui.end_row();
ui.label("Web server:");
ui.horizontal(|ui| {
if self.web_server_running {
if ui.button("🛑 Stop").clicked() {
self.stop_web_server();
}
ui.label(egui::RichText::new("Running").small().weak());
} else {
if ui.button("▶ Start").clicked() {
self.start_web_server();
}
ui.label(egui::RichText::new("Stopped").small().weak());
}
});
ui.end_row();
}); });
ui.add_space(8.0); ui.add_space(8.0);
@ -911,7 +1004,7 @@ impl App {
} }
fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) { fn video_list(&mut self, ctx: &egui::Context, ui: &mut egui::Ui) {
let cards = self.cards(); let cards = self.cards_take();
let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _)); let show_channel = !matches!(self.sidebar_view, SidebarView::Channel(_) | SidebarView::Playlist(_, _));
// Channel metadata banner // Channel metadata banner
@ -1181,6 +1274,7 @@ impl App {
ui.separator(); ui.separator();
} }
}); });
self.cards_cache = cards;
} }
} }
@ -1221,7 +1315,18 @@ impl eframe::App for App {
} }
} }
self.decode_budget = 6; // Drain any decoded thumbnails from the worker thread
while let Ok((path, img)) = self.thumb_result_rx.try_recv() {
self.thumb_pending.remove(&path);
let handle = img.map(|color_image| {
ctx.load_texture(
path.to_string_lossy(),
color_image,
egui::TextureOptions::LINEAR,
)
});
self.textures.insert(path, handle);
}
self.top_bar(ctx); self.top_bar(ctx);
self.channel_panel(ctx); self.channel_panel(ctx);
@ -1286,13 +1391,12 @@ fn spawn_mpv_tracker(sock_path: String, video_id: String, tx: std::sync::mpsc::S
} }
} }
fn decode_thumbnail(ctx: &egui::Context, path: &Path) -> Option<egui::TextureHandle> { fn decode_thumbnail_image(path: &Path) -> Option<egui::ColorImage> {
let image = image::open(path).ok()?; let image = image::open(path).ok()?;
let image = image.thumbnail(384, 216); let image = image.thumbnail(384, 216);
let rgba = image.to_rgba8(); let rgba = image.to_rgba8();
let (w, h) = (rgba.width() as usize, rgba.height() as usize); let (w, h) = (rgba.width() as usize, rgba.height() as usize);
let color_image = egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()); Some(egui::ColorImage::from_rgba_unmultiplied([w, h], rgba.as_raw()))
Some(ctx.load_texture(path.to_string_lossy(), color_image, egui::TextureOptions::LINEAR))
} }
fn file_label(path: &Path) -> String { fn file_label(path: &Path) -> String {

View file

@ -1,6 +1,12 @@
//! Application configuration loaded from `config.toml`.
//!
//! Each top-level section maps to a TOML table. Missing sections get sane
//! defaults so existing config files continue to work after upgrades.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
/// Root configuration object, serialised from/to `config.toml`.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config { pub struct Config {
pub backup: BackupSection, pub backup: BackupSection,
@ -14,11 +20,13 @@ pub struct Config {
pub web: WebSection, pub web: WebSection,
} }
/// `[backup]` table — where to store downloaded videos.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BackupSection { pub struct BackupSection {
pub directory: PathBuf, pub directory: PathBuf,
} }
/// `[player]` table — external player and browser cookie source.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlayerSection { pub struct PlayerSection {
#[serde(default = "default_player")] #[serde(default = "default_player")]
@ -33,6 +41,7 @@ impl Default for PlayerSection {
} }
} }
/// `[ui]` table — egui desktop theme.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UiSection { pub struct UiSection {
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
@ -45,6 +54,7 @@ impl Default for UiSection {
} }
} }
/// `[scheduler]` table — periodic background download schedule.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SchedulerSection { pub struct SchedulerSection {
#[serde(default)] #[serde(default)]
@ -59,15 +69,33 @@ impl Default for SchedulerSection {
} }
} }
/// `[web]` table — built-in HTTP server settings.
///
/// `source_url` is **required for AGPL §13 compliance**: set it to a URL
/// where the running source code can be obtained (e.g. your Codeberg repo).
/// It is shown as a "Source" link in the web UI footer.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WebSection { pub struct WebSection {
#[serde(default = "default_web_port")] #[serde(default = "default_web_port")]
pub port: u16, pub port: u16,
#[serde(default = "default_web_bind")]
/// Address to bind the HTTP server to. Defaults to `127.0.0.1` (localhost only).
/// Set to `0.0.0.0` to accept connections from any interface (not recommended).
pub bind: String,
#[serde(default)]
pub transcode: bool,
/// Public URL to the source repository, shown in the web UI per AGPL §13.
pub source_url: Option<String>,
} }
impl Default for WebSection { impl Default for WebSection {
fn default() -> Self { fn default() -> Self {
Self { port: default_web_port() } Self {
port: default_web_port(),
bind: default_web_bind(),
transcode: false,
source_url: None,
}
} }
} }
@ -76,19 +104,23 @@ fn default_browser() -> String { "firefox".to_string() }
fn default_theme() -> String { "dark".to_string() } fn default_theme() -> String { "dark".to_string() }
fn default_interval_hours() -> u32 { 24 } fn default_interval_hours() -> u32 { 24 }
fn default_web_port() -> u16 { 8080 } fn default_web_port() -> u16 { 8080 }
fn default_web_bind() -> String { "127.0.0.1".to_string() }
impl Config { impl Config {
/// Load and parse `config.toml` from `path`.
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> { pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?; let contents = std::fs::read_to_string(path)?;
toml::from_str(&contents).map_err(|e| e.into()) toml::from_str(&contents).map_err(|e| e.into())
} }
/// Serialise the config back to `path` in pretty TOML.
pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> { pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let contents = toml::to_string_pretty(self)?; let contents = toml::to_string_pretty(self)?;
std::fs::write(path, contents)?; std::fs::write(path, contents)?;
Ok(()) Ok(())
} }
/// Construct a minimal default config pointing `backup.directory` at `dir`.
pub fn default_with_dir(dir: PathBuf) -> Self { pub fn default_with_dir(dir: PathBuf) -> Self {
Self { Self {
backup: BackupSection { directory: dir }, backup: BackupSection { directory: dir },

View file

@ -1,12 +1,25 @@
//! Persistent storage for watched status and playback positions.
//!
//! Uses a bundled SQLite database (`yt-offline.db` by default).
//!
//! # Schema
//!
//! | Table | Columns | Purpose |
//! |---|---|---|
//! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched |
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::Path; use std::path::Path;
/// Thin wrapper around a SQLite connection with schema management.
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
} }
impl Database { impl Database {
/// Open or create the database at `path`, running schema migrations.
pub fn open(path: &Path) -> Result<Self> { pub fn open(path: &Path) -> Result<Self> {
let conn = Connection::open(path)?; let conn = Connection::open(path)?;
let db = Database { conn }; let db = Database { conn };
@ -14,6 +27,7 @@ impl Database {
Ok(db) Ok(db)
} }
/// Open an in-memory database — used in tests.
pub fn open_in_memory() -> Result<Self> { pub fn open_in_memory() -> Result<Self> {
let conn = Connection::open_in_memory()?; let conn = Connection::open_in_memory()?;
let db = Database { conn }; let db = Database { conn };

View file

@ -1,4 +1,27 @@
//! Running `yt-dlp` in the background and surfacing its progress to the UI. //! Running `yt-dlp` in the background and surfacing its progress to the UI.
//!
//! Each call to [`Downloader::start`] spawns a thread that runs `yt-dlp` and
//! pipes stdout/stderr back to the main thread through an `mpsc` channel.
//! The caller polls for updates via [`Downloader::poll`], which drains the
//! channel into each [`Job`]'s log buffer.
//!
//! # yt-dlp command flags used
//!
//! | Flag | Purpose |
//! |---|---|
//! | `--cookies cookies.txt` | Pass browser cookies for age-gated/member videos |
//! | `--write-subs --write-auto-subs` | Download subtitles alongside the video |
//! | `--write-thumbnail` | Download channel/video thumbnails |
//! | `--write-description` | Save video description as a sidecar `.description` file |
//! | `--write-info-json` | Save full metadata as a `.info.json` sidecar |
//! | `--remux-video mkv` | Re-container to MKV (no re-encode) |
//! | `--embed-metadata --embed-info-json --embed-chapters` | Embed rich metadata into the MKV |
//! | `--xattrs` | Store metadata in filesystem extended attributes |
//! | `--sponsorblock-mark all` | Mark (but don't remove) SponsorBlock segments |
//! | `--extractor-args youtube:player_client=web` | Use the web player API to avoid throttling |
//! | `--impersonate Chrome-146:Macos-26` | Impersonate a real browser for bot detection |
//! | `--break-on-existing` | Stop when the archive file records the video as already downloaded |
//! | `--download-archive archive.txt` | Record downloaded IDs to avoid re-downloading |
use std::io::{BufRead, BufReader}; use std::io::{BufRead, BufReader};
use std::path::PathBuf; use std::path::PathBuf;
@ -6,13 +29,17 @@ use std::process::{Command, Stdio};
use std::sync::mpsc::{channel, Receiver}; use std::sync::mpsc::{channel, Receiver};
use std::thread; use std::thread;
/// Describes the kind of YouTube URL being downloaded, which determines the
/// output path template passed to yt-dlp.
pub enum UrlKind { pub enum UrlKind {
/// A channel URL (`/@handle`, `/channel/ID`, or `/c/name`).
Channel { handle: String }, Channel { handle: String },
Playlist, Playlist,
Video, Video,
Unknown, Unknown,
} }
/// Classify a YouTube URL into a [`UrlKind`] by inspecting its path.
pub fn detect_url_kind(url: &str) -> UrlKind { pub fn detect_url_kind(url: &str) -> UrlKind {
if url.contains("playlist?list=") { if url.contains("playlist?list=") {
return UrlKind::Playlist; return UrlKind::Playlist;
@ -39,6 +66,7 @@ fn extract_after<'a>(url: &'a str, marker: &str) -> Option<&'a str> {
if end == 0 { None } else { Some(&rest[..end]) } if end == 0 { None } else { Some(&rest[..end]) }
} }
/// Lifecycle state of a download job.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum JobState { pub enum JobState {
Running, Running,
@ -46,17 +74,22 @@ pub enum JobState {
Failed, Failed,
} }
/// Internal message sent from the yt-dlp thread to the job.
enum Msg { enum Msg {
Line(String), Line(String),
Progress(f32), Progress(f32),
Finished(bool), Finished(bool),
} }
/// A single yt-dlp invocation tracked by the downloader.
pub struct Job { pub struct Job {
pub url: String, pub url: String,
/// Short human-readable path shown in the UI (e.g. `channels/handle/`).
pub label: String, pub label: String,
pub state: JobState, pub state: JobState,
/// Download progress as a fraction in `[0.0, 1.0]`.
pub progress: f32, pub progress: f32,
/// Rolling log buffer — capped at 800 lines to avoid unbounded growth.
pub log: Vec<String>, pub log: Vec<String>,
rx: Receiver<Msg>, rx: Receiver<Msg>,
} }
@ -81,9 +114,12 @@ impl Job {
} }
} }
/// Manages all active and recently completed yt-dlp download jobs.
pub struct Downloader { pub struct Downloader {
pub jobs: Vec<Job>, pub jobs: Vec<Job>,
pub channels_root: PathBuf, pub channels_root: PathBuf,
/// Browser name passed to `--cookies-from-browser` (unused) — cookie file
/// is currently always `cookies.txt`.
pub browser: String, pub browser: String,
} }
@ -92,10 +128,13 @@ impl Downloader {
Self { jobs: Vec::new(), channels_root, browser } Self { jobs: Vec::new(), channels_root, browser }
} }
/// Spawn a yt-dlp process for `url` and track it as a new [`Job`].
///
/// The output path template is derived from `kind` so that channels,
/// playlists, and individual videos land in the right sub-directories.
pub fn start(&mut self, url: String, kind: &UrlKind) { pub fn start(&mut self, url: String, kind: &UrlKind) {
let (tx, rx) = channel(); let (tx, rx) = channel();
let archive_path = self.channels_root.join("archive.txt"); let archive_path = self.channels_root.join("archive.txt");
let browser = self.browser.clone();
let (out_arg, label) = match kind { let (out_arg, label) = match kind {
UrlKind::Channel { handle } => { UrlKind::Channel { handle } => {
@ -121,25 +160,32 @@ impl Downloader {
let mut cmd = Command::new("yt-dlp"); let mut cmd = Command::new("yt-dlp");
cmd.arg("--newline") cmd.arg("--newline")
.arg("--no-color") .arg("--no-color")
.arg("--cookies")
.arg("cookies.txt")
.arg("--write-subs") .arg("--write-subs")
.arg("--write-auto-subs")
.arg("--write-thumbnail") .arg("--write-thumbnail")
.arg("--write-description") .arg("--write-description")
.arg("--write-info-json") .arg("--write-info-json")
.arg("-f") .arg("--remux-video")
.arg("mkv") .arg("mkv")
.arg("--embed-metadata") .arg("--embed-metadata")
.arg("--embed-info-json")
.arg("--embed-chapters")
.arg("--xattrs")
.arg("--sponsorblock-mark")
.arg("all")
.arg("--extractor-args")
.arg("youtube:player_client=web")
.arg("--progress")
.arg("--break-on-existing") .arg("--break-on-existing")
.arg("--download-archive") .arg("--download-archive")
.arg(archive_path.display().to_string()) .arg(archive_path.display().to_string())
.arg("--ignore-errors") .arg("--impersonate")
.arg("Chrome-146:Macos-26")
.arg("-o") .arg("-o")
.arg(&out_arg); .arg(&out_arg)
.arg(&url_for_thread)
if !browser.is_empty() && browser != "none" {
cmd.arg("--cookies-from-browser").arg(&browser);
}
cmd.arg(&url_for_thread)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
@ -178,6 +224,9 @@ impl Downloader {
self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: Vec::new(), rx }); self.jobs.push(Job { url, label, state: JobState::Running, progress: 0.0, log: Vec::new(), rx });
} }
/// Drain pending messages from all job threads into their log buffers.
///
/// Call this regularly from the UI event loop to pick up progress updates.
pub fn poll(&mut self) { pub fn poll(&mut self) {
for job in &mut self.jobs { for job in &mut self.jobs {
job.drain(); job.drain();
@ -187,8 +236,24 @@ impl Downloader {
pub fn any_running(&self) -> bool { pub fn any_running(&self) -> bool {
self.jobs.iter().any(|j| j.state == JobState::Running) self.jobs.iter().any(|j| j.state == JobState::Running)
} }
/// Remove all jobs that have finished (done or failed), keeping only running ones.
pub fn clear_finished(&mut self) {
self.jobs.retain(|j| j.state == JobState::Running);
}
/// Remove a single finished job by index. Silently ignores the request
/// if the job is still running.
pub fn remove_job(&mut self, idx: usize) {
if let Some(j) = self.jobs.get(idx) {
if j.state != JobState::Running {
self.jobs.remove(idx);
}
}
}
} }
/// Parse a yt-dlp `[download] 42.7% …` line into a `[0.0, 1.0]` fraction.
fn parse_progress(line: &str) -> Option<f32> { fn parse_progress(line: &str) -> Option<f32> {
let rest = line.trim_start().strip_prefix("[download]")?.trim_start(); let rest = line.trim_start().strip_prefix("[download]")?.trim_start();
let pct_end = rest.find('%')?; let pct_end = rest.find('%')?;

View file

@ -1,4 +1,23 @@
//! Scanning the `channels/` directory tree into channels, playlists, and videos. //! Scanning the `channels/` directory tree into channels, playlists, and videos.
//!
//! # Directory layout expected
//!
//! ```text
//! channels/
//! <channel-name>/
//! Title [VIDEO_ID].mkv
//! Title [VIDEO_ID].webp ← thumbnail
//! Title [VIDEO_ID].description
//! Title [VIDEO_ID].info.json
//! Title [VIDEO_ID].en.vtt ← subtitle (lang = "en")
//! <playlist-name>/
//! Title [VIDEO_ID].mkv
//! …
//! ```
//!
//! Files that don't match the `Title [ID].ext` naming convention are silently
//! ignored. Hidden directories (name starts with `.`) and directories that
//! contain no recognisable video files are skipped.
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -6,8 +25,18 @@ use std::path::{Path, PathBuf};
const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"]; const VIDEO_EXTS: &[&str] = &["mkv", "mp4", "webm", "m4v", "mov", "avi"];
const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"]; const THUMB_EXTS: &[&str] = &["webp", "jpg", "jpeg", "png"];
/// A single WebVTT subtitle track discovered alongside a video file.
#[derive(Clone, Debug)]
pub struct Subtitle {
/// ISO 639-1/2 language code extracted from the `.lang.vtt` filename suffix.
pub lang: String,
pub path: PathBuf,
}
/// A fully enriched video entry, ready to serve to the UI.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Video { pub struct Video {
/// yt-dlp video ID (the part inside `[…]` in the filename).
pub id: String, pub id: String,
pub title: String, pub title: String,
#[allow(dead_code)] #[allow(dead_code)]
@ -15,11 +44,17 @@ pub struct Video {
pub video_path: Option<PathBuf>, pub video_path: Option<PathBuf>,
pub thumb_path: Option<PathBuf>, pub thumb_path: Option<PathBuf>,
pub description_path: Option<PathBuf>, pub description_path: Option<PathBuf>,
/// Path to the `.info.json` sidecar — used to read duration, chapters, etc.
pub info_path: Option<PathBuf>,
pub subtitles: Vec<Subtitle>,
pub has_live_chat: bool, pub has_live_chat: bool,
/// Duration read from `info.json`; `None` if the sidecar is missing.
pub duration_secs: Option<f64>, pub duration_secs: Option<f64>,
/// Size of the video file on disk; `None` if the video file is missing.
pub file_size: Option<u64>, pub file_size: Option<u64>,
} }
/// A sub-directory inside a channel that contains videos (treated as a playlist).
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Playlist { pub struct Playlist {
pub name: String, pub name: String,
@ -28,6 +63,7 @@ pub struct Playlist {
pub videos: Vec<Video>, pub videos: Vec<Video>,
} }
/// Channel-level metadata pulled from the first available `info.json`.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ChannelMeta { pub struct ChannelMeta {
pub subscriber_count: Option<u64>, pub subscriber_count: Option<u64>,
@ -35,21 +71,32 @@ pub struct ChannelMeta {
pub uploader: Option<String>, pub uploader: Option<String>,
} }
/// A top-level channel directory with all its videos and playlists.
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct Channel { pub struct Channel {
pub name: String, pub name: String,
pub path: PathBuf, pub path: PathBuf,
/// Videos stored directly inside the channel directory (not in a sub-folder).
pub videos: Vec<Video>, pub videos: Vec<Video>,
/// Sub-directories that contain at least one video.
pub playlists: Vec<Playlist>, pub playlists: Vec<Playlist>,
pub meta: Option<ChannelMeta>, pub meta: Option<ChannelMeta>,
/// Cached sum of `videos.len() + playlists[*].videos.len()`.
pub total_videos_cached: usize,
/// Cached sum of all video file sizes.
pub total_size_cached: u64,
} }
impl Channel { impl Channel {
pub fn total_videos(&self) -> usize { pub fn total_videos(&self) -> usize {
self.videos.len() + self.playlists.iter().map(|p| p.videos.len()).sum::<usize>() self.total_videos_cached
} }
} }
/// Scan `root` for channel directories and return them sorted alphabetically.
///
/// Skips hidden directories (names starting with `.`) and directories that
/// contain no recognisable video files.
pub fn scan_channels(root: &Path) -> Vec<Channel> { pub fn scan_channels(root: &Path) -> Vec<Channel> {
let mut channels = Vec::new(); let mut channels = Vec::new();
let Ok(entries) = std::fs::read_dir(root) else { return channels }; let Ok(entries) = std::fs::read_dir(root) else { return channels };
@ -57,9 +104,26 @@ pub fn scan_channels(root: &Path) -> Vec<Channel> {
let path = entry.path(); let path = entry.path();
if !path.is_dir() { continue; } if !path.is_dir() { continue; }
let name = entry.file_name().to_string_lossy().into_owned(); let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') { continue; }
let (videos, playlists) = scan_channel_dir(&path); let (videos, playlists) = scan_channel_dir(&path);
if videos.is_empty() && playlists.is_empty() { continue; }
let meta = load_channel_meta(&videos); let meta = load_channel_meta(&videos);
channels.push(Channel { name, path, videos, playlists, meta }); let total_videos_cached =
videos.len() + playlists.iter().map(|p| p.videos.len()).sum::<usize>();
let total_size_cached = videos
.iter()
.chain(playlists.iter().flat_map(|p| p.videos.iter()))
.filter_map(|v| v.file_size)
.sum();
channels.push(Channel {
name,
path,
videos,
playlists,
meta,
total_videos_cached,
total_size_cached,
});
} }
channels.sort_by_key(|c| c.name.to_lowercase()); channels.sort_by_key(|c| c.name.to_lowercase());
channels channels
@ -134,15 +198,28 @@ struct RawVideo {
thumb_path: Option<PathBuf>, thumb_path: Option<PathBuf>,
description_path: Option<PathBuf>, description_path: Option<PathBuf>,
info_path: Option<PathBuf>, info_path: Option<PathBuf>,
subtitles: Vec<Subtitle>,
has_live_chat: bool, has_live_chat: bool,
} }
fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<RawVideo> { fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<RawVideo> {
let mut by_stem: BTreeMap<String, RawVideo> = BTreeMap::new(); let mut by_stem: BTreeMap<String, RawVideo> = BTreeMap::new();
let mut pending_subs: Vec<(String, String, PathBuf)> = Vec::new();
for entry in entries { for entry in entries {
let path = entry.path(); let path = entry.path();
if !path.is_file() { continue; } if !path.is_file() { continue; }
let file_name = entry.file_name().to_string_lossy().into_owned(); let file_name = entry.file_name().to_string_lossy().into_owned();
// Subtitles have stems like "Title [id].en.vtt" — strip the .vtt and trailing .lang
if let Some(sub_stem) = file_name.strip_suffix(".vtt") {
if let Some(dot) = sub_stem.rfind('.') {
let lang = sub_stem[dot + 1..].to_string();
let video_stem = sub_stem[..dot].to_string();
pending_subs.push((video_stem, lang, path));
continue;
}
}
let Some((stem, kind)) = classify(&file_name) else { continue }; let Some((stem, kind)) = classify(&file_name) else { continue };
let Some((title, id)) = parse_stem(stem) else { continue }; let Some((title, id)) = parse_stem(stem) else { continue };
let raw = by_stem.entry(stem.to_string()).or_insert_with(|| RawVideo { let raw = by_stem.entry(stem.to_string()).or_insert_with(|| RawVideo {
@ -153,6 +230,7 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
thumb_path: None, thumb_path: None,
description_path: None, description_path: None,
info_path: None, info_path: None,
subtitles: Vec::new(),
has_live_chat: false, has_live_chat: false,
}); });
match kind { match kind {
@ -164,6 +242,14 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
FileKind::Other => {} FileKind::Other => {}
} }
} }
for (video_stem, lang, path) in pending_subs {
if let Some(raw) = by_stem.get_mut(&video_stem) {
raw.subtitles.push(Subtitle { lang, path });
}
}
for raw in by_stem.values_mut() {
raw.subtitles.sort_by(|a, b| a.lang.cmp(&b.lang));
}
by_stem.into_values().collect() by_stem.into_values().collect()
} }
@ -184,6 +270,8 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
video_path: raw.video_path, video_path: raw.video_path,
thumb_path: raw.thumb_path, thumb_path: raw.thumb_path,
description_path: raw.description_path, description_path: raw.description_path,
info_path: raw.info_path,
subtitles: raw.subtitles,
has_live_chat: raw.has_live_chat, has_live_chat: raw.has_live_chat,
duration_secs, duration_secs,
file_size, file_size,
@ -193,6 +281,9 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
videos videos
} }
/// Scan a single flat directory for video files and return enriched `Video` entries.
///
/// Used when rescanning a playlist directory without a full library reload.
pub fn scan_video_files(dir: &Path) -> Vec<Video> { pub fn scan_video_files(dir: &Path) -> Vec<Video> {
let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() };
let raws = collect_raw_videos(entries.flatten().filter(|e| e.path().is_file())); let raws = collect_raw_videos(entries.flatten().filter(|e| e.path().is_file()));

View file

@ -1,3 +1,16 @@
//! yt-offline — desktop and web app for archiving YouTube content with yt-dlp.
//!
//! # Usage
//!
//! * **GUI mode** (default): `yt-offline`
//! * **Web mode**: `yt-offline --web [PORT]` — starts a headless HTTP server
//! on the configured port (default 8080).
//!
//! Configuration is read from `config.toml` in the current working directory.
//! See [`config`] for all available options.
//!
//! Licensed under the GNU Affero General Public License v3 or later (AGPL-3.0+).
//! Source code must be made available to network users per AGPL §13.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
mod app; mod app;
@ -6,7 +19,6 @@ mod database;
mod downloader; mod downloader;
mod library; mod library;
mod theme; mod theme;
mod tray;
mod web; mod web;
fn main() -> eframe::Result<()> { fn main() -> eframe::Result<()> {

View file

@ -1,36 +0,0 @@
use tray_icon::{TrayIconBuilder, menu::Menu};
use std::path::PathBuf;
pub fn create_tray_icon(icon_path: Option<PathBuf>) -> Result<tray_icon::TrayIcon, Box<dyn std::error::Error>> {
let menu = Menu::new();
let icon = if let Some(path) = icon_path {
load_icon_from_file(&path)?
} else {
create_default_icon()?
};
let tray = TrayIconBuilder::new()
.with_menu(Box::new(menu))
.with_tooltip("YouTube Backup\nDownload and manage YouTube channel backups")
.with_icon(icon)
.build()?;
Ok(tray)
}
fn load_icon_from_file(path: &PathBuf) -> Result<tray_icon::Icon, Box<dyn std::error::Error>> {
let img = image::open(path)?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
Ok(tray_icon::Icon::from_rgba(rgba.into_raw(), w, h)?)
}
fn create_default_icon() -> Result<tray_icon::Icon, Box<dyn std::error::Error>> {
let mut rgba = vec![0u8; 64 * 64 * 4];
for chunk in rgba.chunks_mut(4) {
chunk[0] = 255; // red
chunk[3] = 255; // alpha
}
Ok(tray_icon::Icon::from_rgba(rgba, 64, 64)?)
}

1168
src/web.rs

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[Desktop Entry] [Desktop Entry]
Type=Application Type=Application
Name=YouTube Backup Name=YouTube Offline
Comment=Download and manage YouTube channel backups Comment=Download and manage YouTube channel backups
Exec=youtube-backup Exec=youtube-backup
Icon=youtube-backup Icon=youtube-backup

Binary file not shown.