Migrate thư viện TV sang home server và triển khai Jellyfin bằng Docker
Với một home server đơn giản chỉ có một HDD dữ liệu, cách triển khai hiệu quả là giữ media thành thư viện chuẩn trên /data/media/tv, mount read-only vào Jellyfin container, dùng Shows làm loại library cho TV series, và truy cập từ xa qua Tailscale thay vì public port trực tiếp.
Trạng thái cuối cùng
Jellyfin đã chạy trên Mac mini Ubuntu bằng Docker Compose và đọc được thư viện TV từ HDD.
Data flow thực tế:
Laptop
│
│ rsync qua SSH/Tailscale
▼
Mac mini
│
├── /data/media/tv # media trên HDD
│
└── Docker
└── Jellyfin
└── /media/tv:ro # bind mount read-onlyLibrary được tổ chức theo series và season:
/data/media/tv/
├── Friends (1994)/
│ ├── Season 01/
│ ├── Season 02/
│ └── ...
├── Silicon Valley (2014)/
│ ├── Season 01/
│ └── ...
└── The Office (US) (2005)/
├── Season 01/
└── ...Jellyfin phải tạo library với:
Content type: Shows
Folder: /media/tvKhông dùng Movies cho cấu trúc này. Khi từng episode bị hiện thành hàng trăm movie riêng lẻ, nguyên nhân trong lần setup này là library đã được tạo nhầm với Content type = Movies.
Environment đã xác minh
Server:
Hardware: Apple Mac mini Late 2014 (Macmini7,1)
OS: Ubuntu 26.04.1 LTS
Kernel: Linux 7.0.0-31-generic
User: <server-user>
UID: 1000
GID: 1000Storage:
/dev/sda1 -> / # Kingmax SSD 120GB
/dev/sdb1 -> /data # Apple HDD 1TB 5400 rpm, ext4Media cuối cùng:
/data/media/tvQuyền thư mục đã xác minh:
drwxr-xr-x <server-user> <server-user> /data/media/tvUser <server-user> thuộc group docker, nên có thể chạy Docker CLI không cần sudo.
Tailscale đã được cài và server có thể được truy cập qua tailnet. SSH đã được cấu hình key-only từ trước.
Quyết định kiến trúc
Media nằm trên HDD
Media là workload chủ yếu đọc tuần tự và dung lượng lớn, nên đặt trên HDD 1TB:
/data/media/tvVới vài stream cá nhân, HDD 5400 rpm phù hợp hơn việc tiêu tốn SSD chỉ để chứa video.
Không triển khai RAID, ZFS, mergerfs hay SSD cache cho media trong setup hiện tại vì chỉ có một HDD dữ liệu và workload chưa cần các lớp phức tạp đó.
Jellyfin chỉ được đọc media
Bind mount media bằng :ro:
- /data/media/tv:/media/tv:roĐiều này tách quyền quản lý ứng dụng khỏi dữ liệu gốc. Jellyfin có thể scan và stream nhưng không được sửa/xóa file media qua mount này.
Truy cập từ xa bằng Tailscale
Không mở trực tiếp port Jellyfin ra Internet qua router.
Mô hình hiện tại:
Client ngoài nhà
│
│ Tailscale
▼
Mac mini
│
└── Jellyfin :8096Port 8096 là cổng HTTP mặc định mà Jellyfin lắng nghe trong setup này.
Với người khác, nên share riêng node <server-hostname> bằng Tailscale Machine Sharing thay vì cho họ tham gia toàn bộ tailnet. Người xem vẫn nên có một Jellyfin user riêng.
Chuẩn hóa thư viện trước khi đưa vào Jellyfin
Source ban đầu trên laptop có cấu trúc riêng cho video và VTT:
data/
├── videos/
│ ├── friends/
│ ├── silicon_valley/
│ └── the_office/
└── subtitles/bilingual/VTT/
├── friends/
├── silicon_valley/
└── the_office/Jellyfin dễ nhận diện TV series hơn khi video và external subtitle của cùng episode nằm cạnh nhau dưới series/season tương ứng.
Đích cuối:
Series/
└── Season 01/
├── Series_S01E01.mp4
├── Series_S01E01.vtt
├── Series_S01E02.mp4
└── Series_S01E02.vttTên series/year như Friends (1994) và Silicon Valley (2014) giúp metadata matcher phân biệt title tốt hơn. Đây là convention hữu ích, không phải điều kiện tuyệt đối để Jellyfin chạy.
Migrate dữ liệu bằng rsync
Kiểm tra dung lượng trước khi copy
Trên laptop:
du -sh .
du -sh videos
du -sh subtitles
du -sh videos/*Trên server:
ssh <server-hostname>
df -h /dataTạo staging trên server
mkdir -p /data/media/tv-import/videos
mkdir -p /data/media/tv-import/subtitles
mkdir -p /data/media/tvCopy video và subtitle
Chạy từ laptop tại thư mục chứa videos/ và subtitles/:
rsync -avh --info=progress2 --partial \
videos/ \
<server-hostname>:/data/media/tv-import/videos/
rsync -avh --info=progress2 --partial \
subtitles/bilingual/VTT/ \
<server-hostname>:/data/media/tv-import/subtitles/Các option đáng nhớ:
-a: archive mode, giữ cấu trúc và metadata file phù hợp.-v: verbose.-h: kích thước dễ đọc.--info=progress2: hiển thị tiến độ toàn bộ transfer.--partial: giữ partial file khi transfer bị ngắt để lần sau có thể tiếp tục hiệu quả hơn.
rsync được chọn thay cho scp -r vì migration media lớn có thể bị ngắt và cần resume/re-run.
Không dùng --checksum trong mỗi lần copy thông thường vì nó buộc rsync hash toàn bộ source và destination để quyết định file nào thay đổi. Có thể dùng checksum ở bước verification cuối khi muốn kiểm tra mạnh hơn.
Verification sau transfer
So sánh dung lượng và file count ở hai phía trước khi xóa source trên laptop.
Laptop:
du -sh videos
du -sh subtitles/bilingual/VTT
find videos -type f | wc -l
find subtitles/bilingual/VTT -type f | wc -lServer:
du -sh /data/media/tv-import/videos
du -sh /data/media/tv-import/subtitles
find /data/media/tv-import/videos -type f | wc -l
find /data/media/tv-import/subtitles -type f | wc -lCó thể chạy dry-run checksum comparison từ laptop:
rsync -avhnc videos/ <server-hostname>:/data/media/tv-import/videos/
rsync -avhnc subtitles/bilingual/VTT/ <server-hostname>:/data/media/tv-import/subtitles/Trong đó:
-n: dry run, không sửa gì.-c: so sánh bằng checksum.
Nếu không còn changed-file cần transfer thì source/destination khớp theo phép so sánh của rsync.
Không xóa bản laptop trước khi:
- transfer đã verify;
- Jellyfin nhìn thấy library;
- đã phát thử một số episode;
- cấu trúc series/season đã đúng.
Merge staging vào layout Jellyfin
Một lỗi thực tế trong lần migrate này là dùng:
seq -w 1 5với giả định output sẽ là:
01 02 03 04 05Nhưng khi số lớn nhất chỉ có một digit, output thực tế là:
1 2 3 4 5Hậu quả: script tìm season_1 thay vì season_01, tạo destination Season 1 thay vì Season 01, và không move được video.
Cách ổn định hơn là format explicit:
for i in $(seq 1 5); do
n=$(printf "%02d" "$i")
...
doneFriends
Destination:
/data/media/tv/Friends (1994)/Season XXSilicon Valley
Destination:
/data/media/tv/Silicon Valley (2014)/Season XXScript sửa sau lỗi seq -w:
Các block bên dưới dùng mv để di chuyển file ngay trong filesystem. Trước khi chạy lại trên library khác, liệt kê source/destination và thử với một season nhỏ; không giả định layout mới giống hệt staging đã ghi trong note này.
for i in $(seq 1 5); do
n=$(printf "%02d" "$i")
correct="/data/media/tv/Silicon Valley (2014)/Season $n"
wrong="/data/media/tv/Silicon Valley (2014)/Season $i"
mkdir -p "$correct"
if [ -d "$wrong" ] && [ "$wrong" != "$correct" ]; then
find "$wrong" -maxdepth 1 -type f -exec mv -t "$correct" -- {} +
rmdir "$wrong" 2>/dev/null || true
fi
video_dir="/data/media/tv-import/videos/silicon_valley/season_$n"
if [ -d "$video_dir" ]; then
find "$video_dir" -maxdepth 1 -type f -exec mv -t "$correct" -- {} +
fi
doneThe Office
Destination:
/data/media/tv/The Office (US) (2005)/Season XXPattern đã dùng:
for i in $(seq 1 9); do
n=$(printf "%02d" "$i")
dest="/data/media/tv/The Office (US) (2005)/Season $n"
mkdir -p "$dest"
video_dir="/data/media/tv-import/videos/the_office/season_$n"
if [ -d "$video_dir" ]; then
find "$video_dir" -maxdepth 1 -type f -exec mv -t "$dest" -- {} +
fi
sub_dir=$(find /data/media/tv-import/subtitles/the_office \
-maxdepth 1 -type d -name "*${n}" -print -quit)
if [ -n "$sub_dir" ]; then
find "$sub_dir" -maxdepth 1 -type f -exec mv -t "$dest" -- {} +
fi
donefind ... -exec mv được dùng thay cho:
mv "$dir/"*vì cách dùng glob có thể báo cannot stat ... * khi directory rỗng và xử lý kém hơn với một số edge case.
Caveat về tên có \_
Trong staging xuất hiện directory được ls hiển thị như:
season\_01
season\_02
...Điều này làm script hard-code season_01 không match ở nhánh subtitle.
Cách tránh phụ thuộc chính xác vào phần prefix trong trường hợp đó:
sub_dir=$(find /data/media/tv-import/subtitles/<series> \
-maxdepth 1 -type d -name "*${n}" -print -quit)Trước khi kết luận tên file thực sự chứa backslash, nên kiểm tra bằng shell-safe representation:
printf '%q\n' /data/media/tv-import/subtitles/friends/*ls có thể escape ký tự trong output, nên output hiển thị không phải lúc nào cũng phản ánh literal filename theo cách dễ đọc bằng mắt.
Audit media sau khi merge
Episode có video nhưng thiếu VTT
find /data/media/tv -type f -name '*.mp4' -print0 |
while IFS= read -r -d '' video; do
base="${video%.mp4}"
if [ ! -f "${base}.vtt" ]; then
echo "Missing subtitle: $video"
fi
doneKết quả thực tế:
Missing subtitle: /data/media/tv/Silicon Valley (2014)/Season 02/SiliconValley_S02E07.mp4
Missing subtitle: /data/media/tv/The Office (US) (2005)/Season 03/TheOffice_S03E05.mp4VTT không có video tương ứng
find /data/media/tv -type f -name '*.vtt' -print0 |
while IFS= read -r -d '' sub; do
base="${sub%.vtt}"
if [ ! -f "${base}.mp4" ]; then
echo "Missing video: $sub"
fi
doneKết quả thực tế:
Missing video: /data/media/tv/Silicon Valley (2014)/Season 03/SiliconValley_S03E02.vttTổng tại thời điểm audit:
465 MP4
464 VTT
150G /data/media/tvBa mismatch trên không ngăn Jellyfin scan và phát phần còn lại của library.
Permission của media
User chạy container là:
uid=1000(<server-user>)
gid=1000(<server-user>)Permission được chuẩn hóa bằng:
sudo chown -R <server-user>:<server-user> /data/media/tv
find /data/media/tv -type d -exec chmod 755 {} \;
find /data/media/tv -type f -exec chmod 644 {} \;Không dùng:
chmod -R 777Jellyfin chỉ cần khả năng traverse directory và đọc file media.
Docker Compose của Jellyfin
Compose directory:
mkdir -p /data/docker/compose/jellyfin
cd /data/docker/compose/jellyfinAppdata đang dùng trong triển khai thực tế:
mkdir -p /data/docker/appdata/jellyfin/config
mkdir -p /data/docker/appdata/jellyfin/cacheLưu ý: /data nằm trên HDD trong server hiện tại, nên config/cache của Jellyfin cũng đang ở HDD. Trước đó có cân nhắc đặt config/cache trên SSD để tách small-file workload khỏi HDD media, nhưng chưa thực hiện migration đó. Không nên mô tả setup hiện tại như thể appdata đã nằm trên SSD.
compose.yaml:
services:
jellyfin:
image: jellyfin/jellyfin:latest
container_name: jellyfin
user: "1000:1000"
ports:
- "8096:8096"
volumes:
- /data/docker/appdata/jellyfin/config:/config
- /data/docker/appdata/jellyfin/cache:/cache
- /data/media/tv:/media/tv:ro
restart: unless-stoppedImage dùng tag latest. Điều này không tự update container đang chạy, nhưng future pull/recreate có thể lấy image khác. Note không ghi digest/version resolved tại thời điểm triển khai, nên cần pin tag/digest nếu muốn deployment reproducible.
Ý nghĩa quan trọng:
Host path Container path
/data/media/tv -> /media/tvDo đó trong Jellyfin UI phải add:
/media/tvKhông phải:
/data/media/tvContainer không tự nhìn thấy filesystem của host; nó chỉ thấy những path được bind mount vào namespace của nó.
Validate và start
docker compose config
docker compose up -d
docker compose ps
docker compose logs --tail=50 jellyfinXác minh container nhìn thấy media
docker exec jellyfin ls /media/tvKỳ vọng có:
Friends (1994)
Silicon Valley (2014)
The Office (US) (2005)Kiểm tra sâu hơn:
docker exec jellyfin ls "/media/tv/Friends (1994)/Season 01" | headNếu thấy .mp4 và .vtt, bind mount đã hoạt động.
Lỗi library bị nhận thành Movies
Failure mode thực tế:
Movies
1-100 of 465Mỗi episode bị hiển thị như một movie riêng.
Root cause đã xác định: /media/tv được add dưới library type Movies.
Fix:
- Remove library entry sai trong Jellyfin.
- Add Media Library lại.
- Chọn:
Content type: Shows
Folder: /media/tvVì media mount :ro, việc remove library khỏi Jellyfin không xóa file gốc trong /data/media/tv.
Sau khi scan đúng, Jellyfin có thể group theo:
Series
└── Season
└── Episodethay vì hiển thị 465 file rời.
Truy cập Jellyfin trong LAN và qua Tailscale
Lấy địa chỉ server:
hostname -ILấy riêng Tailscale IPv4:
tailscale ip -4Truy cập:
http://<SERVER-IP>:8096Nếu MagicDNS hoạt động, có thể thử:
http://<server-hostname>:8096Không cần port-forward router để sử dụng qua Tailscale.
Share cho người khác
Đối với một người xem bên ngoài mạng nhà, mô hình ưu tiên là:
Tailscale Admin Console
→ Machines
→ <server-hostname>
→ ShareShare riêng node <server-hostname> thay vì cấp quyền vào toàn bộ tailnet.
Người nhận:
- cài Tailscale trên điện thoại/laptop;
- đăng nhập và accept share;
- kết nối tới Jellyfin bằng IP Tailscale mà họ nhìn thấy cho node được share;
- đăng nhập bằng Jellyfin user riêng.
Tailscale account và Jellyfin account là hai lớp độc lập:
Tailscale
└── ai được phép kết nối tới server?
Jellyfin
└── ai được phép đăng nhập và xem library?Không chia sẻ Jellyfin admin account cho người xem thông thường.
Hardware transcoding: chưa bật
Không bật hardware acceleration ngay trong bước cài ban đầu.
Thứ tự tốt hơn:
Direct Play hoạt động
→ xác định có thật sự cần transcoding không
→ kiểm tra GPU/device
→ mới cấu hình HWAKiểm tra Linux render device:
ls -l /dev/driMac mini 2014 thuộc thế hệ Intel cũ; tài liệu Jellyfin nêu QSV trên Linux được ưu tiên cho phần cứng mainstream được hỗ trợ, nhưng pre-Broadwell cần xem xét VA-API để tương thích. Cần xác minh GPU cụ thể của máy trước khi áp dụng HWA; chưa có cấu hình VA-API nào được triển khai trong lần setup này.
Những điều không nên làm trong setup này
Không cần:
- RAID chỉ để chạy một HDD media;
- ZFS/mergerfs khi chưa có nhu cầu pooling nhiều disk;
- SSD cache cho media chỉ để “tăng tốc” streaming;
- public-forward trực tiếp
8096ra Internet; chmod -R 777;- xóa source trên laptop trước verification;
- bật HWA trước khi biết transcoding có phải bottleneck hay không.
Các quyết định này cần xem xét lại nếu sau này:
- thêm nhiều HDD;
- cần redundancy hoặc snapshot;
- có nhiều concurrent remote stream;
- phải transcode 4K/HEVC thường xuyên;
- muốn public service cho người không dùng Tailscale;
- appdata/cache trên HDD trở thành bottleneck.
Checklist dùng lại
Sau một migration hoặc rebuild, có thể kiểm tra theo thứ tự:
df -h /data
find /data/media/tv -type f -name '*.mp4' | wc -l
find /data/media/tv -type f -name '*.vtt' | wc -l
du -sh /data/media/tv
docker compose config
docker compose ps
docker compose logs --tail=50 jellyfin
docker exec jellyfin ls /media/tvTrong Jellyfin:
Library type = Shows
Folder = /media/tvSau cùng:
- mở một series;
- kiểm tra season grouping;
- phát thử vài episode;
- kiểm tra remote access qua Tailscale;
- chỉ xóa bản media cũ khi đã chắc chắn migration ổn.
References
Nguồn chính thức đã được dùng trong quá trình quyết định và kiểm chứng:
- Jellyfin — Container installation
- Jellyfin — TV Shows
- Jellyfin — Movies
- Jellyfin — Networking
- Jellyfin — Tailscale
- Jellyfin — Hardware acceleration
- Jellyfin — Intel hardware acceleration
- Jellyfin — Backup and Restore
- Jellyfin — Configuration
Community discussions đã được dùng để đối chiếu practice thực tế: